@astral/ui 4.52.0 → 4.52.1

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 (37) hide show
  1. package/components/DatePicker/hooks/useDatePickerOptions/useDatePickerOptions.d.ts +2 -0
  2. package/components/DatePicker/hooks/useDatePickerOptions/useDatePickerOptions.js +2 -1
  3. package/components/DatePicker/hooks/useMaskedValue/useMaskedValue.d.ts +4 -0
  4. package/components/DatePicker/hooks/useMaskedValue/useMaskedValue.js +37 -8
  5. package/components/DatePicker/useLogic/useLogic.d.ts +1 -0
  6. package/components/DatePicker/useLogic/useLogic.js +19 -13
  7. package/components/DateRangePicker/useLogic/useLogic.d.ts +2 -0
  8. package/components/DateRangePicker/useLogic/useLogic.js +27 -3
  9. package/components/DateRangePicker/useLogic/utils/index.d.ts +0 -1
  10. package/components/DateRangePicker/useLogic/utils/index.js +0 -1
  11. package/components/utils/date/format/index.d.ts +1 -0
  12. package/components/utils/date/format/index.js +1 -0
  13. package/{node/components/DateRangePicker/useLogic/utils → components/utils/date/format}/isMaskedDateSyntacticallyComplete/isMaskedDateSyntacticallyComplete.d.ts +1 -1
  14. package/components/utils/date/format/parseDate/parseDate.d.ts +1 -0
  15. package/components/utils/date/format/parseDate/parseDate.js +42 -13
  16. package/node/components/DatePicker/hooks/useDatePickerOptions/useDatePickerOptions.d.ts +2 -0
  17. package/node/components/DatePicker/hooks/useDatePickerOptions/useDatePickerOptions.js +2 -1
  18. package/node/components/DatePicker/hooks/useMaskedValue/useMaskedValue.d.ts +4 -0
  19. package/node/components/DatePicker/hooks/useMaskedValue/useMaskedValue.js +35 -6
  20. package/node/components/DatePicker/useLogic/useLogic.d.ts +1 -0
  21. package/node/components/DatePicker/useLogic/useLogic.js +19 -13
  22. package/node/components/DateRangePicker/useLogic/useLogic.d.ts +2 -0
  23. package/node/components/DateRangePicker/useLogic/useLogic.js +28 -4
  24. package/node/components/DateRangePicker/useLogic/utils/index.d.ts +0 -1
  25. package/node/components/DateRangePicker/useLogic/utils/index.js +0 -1
  26. package/node/components/utils/date/format/index.d.ts +1 -0
  27. package/node/components/utils/date/format/index.js +1 -0
  28. package/{components/DateRangePicker/useLogic/utils → node/components/utils/date/format}/isMaskedDateSyntacticallyComplete/isMaskedDateSyntacticallyComplete.d.ts +1 -1
  29. package/node/components/utils/date/format/parseDate/parseDate.d.ts +1 -0
  30. package/node/components/utils/date/format/parseDate/parseDate.js +45 -13
  31. package/package.json +1 -1
  32. /package/components/{DateRangePicker/useLogic/utils → utils/date/format}/isMaskedDateSyntacticallyComplete/index.d.ts +0 -0
  33. /package/components/{DateRangePicker/useLogic/utils → utils/date/format}/isMaskedDateSyntacticallyComplete/index.js +0 -0
  34. /package/components/{DateRangePicker/useLogic/utils → utils/date/format}/isMaskedDateSyntacticallyComplete/isMaskedDateSyntacticallyComplete.js +0 -0
  35. /package/node/components/{DateRangePicker/useLogic/utils → utils/date/format}/isMaskedDateSyntacticallyComplete/index.d.ts +0 -0
  36. /package/node/components/{DateRangePicker/useLogic/utils → utils/date/format}/isMaskedDateSyntacticallyComplete/index.js +0 -0
  37. /package/node/components/{DateRangePicker/useLogic/utils → utils/date/format}/isMaskedDateSyntacticallyComplete/isMaskedDateSyntacticallyComplete.js +0 -0
@@ -30,6 +30,8 @@ type UseMaskedValueAndSelectedBaseDateReturn = {
30
30
  */
31
31
  value: string;
32
32
  };
33
+ /** При blur неполной даты - в onChange уходит Invalid Date (тот же стейт, что у маски). */
34
+ onMaskedValueBlur: () => void;
33
35
  };
34
36
  /**
35
37
  * хук объединяющий повторяющуюся логику в работе DatePicker и RangeDatePicker:
@@ -12,7 +12,7 @@ import { useSelectedBaseDate } from '../useSelectedBaseDate';
12
12
  */
13
13
  export const useDatePickerOptions = ({ onChange, mask, currentValue, minDate, maxDate, monthOffset, onDatePick, }) => {
14
14
  const baseDate = useBaseDateInRange({ minDate, maxDate, monthOffset });
15
- const { maskedValue, onMaskedValueChange, onMaskedDateChange } = useMaskedValue({
15
+ const { maskedValue, onMaskedValueChange, onMaskedValueBlur, onMaskedDateChange, } = useMaskedValue({
16
16
  currentValue,
17
17
  mask,
18
18
  onChangeValue: onChange,
@@ -39,5 +39,6 @@ export const useDatePickerOptions = ({ onChange, mask, currentValue, minDate, ma
39
39
  onChange: handleDatePick,
40
40
  date: selectedBaseDate || baseDate,
41
41
  },
42
+ onMaskedValueBlur,
42
43
  };
43
44
  };
@@ -19,6 +19,10 @@ type Returned = {
19
19
  * Изменение maskedValue
20
20
  * */
21
21
  onMaskedValueChange: (value: string) => void;
22
+ /**
23
+ * Реакция на blur maskedValue
24
+ * */
25
+ onMaskedValueBlur: () => void;
22
26
  };
23
27
  /**
24
28
  * Хук для управления значением для MaskField
@@ -1,24 +1,48 @@
1
1
  import { isDate } from '@astral/utils/date/isDate';
2
- import { useEffect, useState } from 'react';
3
- import { formatDate, parseDate } from '../../../utils/date';
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { formatDate, isMaskedDateSyntacticallyComplete, parseDate, } from '../../../utils/date';
4
4
  /**
5
5
  * Хук для управления значением для MaskField
6
6
  */
7
7
  export const useMaskedValue = ({ currentValue, mask, onChangeValue, }) => {
8
8
  const [maskedValue, setMaskedValue] = useState(() => currentValue ? formatDate(currentValue, mask) : '');
9
+ const maskedValueRef = useRef(maskedValue);
10
+ /**
11
+ * Ввод в маску: синхронизируем локальное значение и ref (ref нужен на blur и после выбора даты из календаря,
12
+ * чтобы не читать устаревший state).
13
+ * `onChangeValue` вызываем только при пустой строке (`null`) или когда маска синтаксически полная —
14
+ * иначе partial не уходит наружу до завершения ввода или до blur (см. `handleMaskedValueBlur`).
15
+ */
9
16
  const handleMaskedValueChange = (value) => {
10
17
  setMaskedValue(value);
11
- // При отсутствии value указываем null, так как если задать undefined, то отобразится defaultValue при наличии
18
+ maskedValueRef.current = value;
12
19
  if (!value) {
13
20
  onChangeValue?.(null, value);
21
+ return;
22
+ }
23
+ if (!isMaskedDateSyntacticallyComplete(value, mask)) {
24
+ return;
14
25
  }
15
- else {
16
- onChangeValue?.(parseDate(value, mask), value);
26
+ onChangeValue?.(parseDate(value, mask), value);
27
+ };
28
+ /**
29
+ * Blur поля: если осталась непустая неполная маска — один раз отдаём `Invalid Date` в `onChangeValue`,
30
+ * чтобы формы могли показать ошибку. Пустая строка и полная маска не дублируют уже отправленный `onChange`.
31
+ */
32
+ const handleMaskedValueBlur = () => {
33
+ const currentMaskedValue = maskedValueRef.current;
34
+ if (!currentMaskedValue) {
35
+ return;
36
+ }
37
+ if (isMaskedDateSyntacticallyComplete(currentMaskedValue, mask)) {
38
+ return;
17
39
  }
40
+ onChangeValue?.(new Date(Number.NaN), currentMaskedValue);
18
41
  };
19
42
  const handleChangeMaskedDate = (date) => {
20
43
  const formatted = formatDate(date, mask);
21
44
  setMaskedValue(formatted);
45
+ maskedValueRef.current = formatted;
22
46
  onChangeValue?.(date, formatted);
23
47
  };
24
48
  // здесь происходит реакция на изменение value из вне (управляемый компонент)
@@ -26,6 +50,7 @@ export const useMaskedValue = ({ currentValue, mask, onChangeValue, }) => {
26
50
  // если новое значение пустое, то сбрасываем значение MaskField
27
51
  if (!currentValue) {
28
52
  setMaskedValue('');
53
+ maskedValueRef.current = '';
29
54
  return;
30
55
  }
31
56
  // здесь обрабатывается сценарий, когда в инпут вводится невалидная дата и при этом currentValue становится Invalid Date
@@ -33,16 +58,20 @@ export const useMaskedValue = ({ currentValue, mask, onChangeValue, }) => {
33
58
  if (!isDate(currentValue) && maskedValue) {
34
59
  return;
35
60
  }
36
- // проверяем равны ли даты
37
- const isEqualValueAndMaskedDate = currentValue.getTime() === parseDate(maskedValue, mask).getTime();
61
+ // сравнение только при полной маске, иначе getTime() от parseDate может быть NaN
62
+ const isEqualValueAndMaskedDate = isMaskedDateSyntacticallyComplete(maskedValue, mask) &&
63
+ currentValue.getTime() === parseDate(maskedValue, mask).getTime();
38
64
  // если даты не равны, то значит изменился currentValue из вне и надо синхронизировать maskedValue
39
65
  if (!isEqualValueAndMaskedDate) {
40
- setMaskedValue(formatDate(currentValue, mask));
66
+ const formatted = formatDate(currentValue, mask);
67
+ setMaskedValue(formatted);
68
+ maskedValueRef.current = formatted;
41
69
  }
42
70
  }, [currentValue]);
43
71
  return {
44
72
  maskedValue,
45
73
  onMaskedValueChange: handleMaskedValueChange,
74
+ onMaskedValueBlur: handleMaskedValueBlur,
46
75
  onMaskedDateChange: handleChangeMaskedDate,
47
76
  };
48
77
  };
@@ -13,6 +13,7 @@ export declare const useLogic: ({ label, value, maxDate, minDate, mask, onOpen,
13
13
  };
14
14
  DatePickerInputProps: {
15
15
  value: string;
16
+ onBlur: () => void;
16
17
  };
17
18
  onAccept: ((value: string, maskRef: import("imask").default.InputMask<import("imask").default.AnyMaskedOptions>, e?: InputEvent | undefined, onChange?: ((changeValue: string) => void) | undefined) => void) | undefined;
18
19
  pickerProps: import("../types").PickerProps;
@@ -4,7 +4,6 @@ import { usePopover } from '../../usePopover';
4
4
  import { useViewportType } from '../../useViewportType';
5
5
  import { DEFAULT_DATE_MASK } from '../constants';
6
6
  import { useDatePickerOptions } from '../hooks/useDatePickerOptions';
7
- import { useMaskedValue } from '../hooks/useMaskedValue';
8
7
  import { DEFAULT_MAX_DATE, DEFAULT_MIN_DATE } from '../MinMaxDateContext';
9
8
  export const useLogic = ({ label, value, maxDate = DEFAULT_MAX_DATE, minDate = DEFAULT_MIN_DATE, mask = DEFAULT_DATE_MASK, onOpen, onClose, onBlur, onChange, forwardedRef, }) => {
10
9
  const ref = useForwardedRef(forwardedRef);
@@ -12,21 +11,11 @@ export const useLogic = ({ label, value, maxDate = DEFAULT_MAX_DATE, minDate = D
12
11
  const { isOpen, actions } = usePopover();
13
12
  const { open, close } = actions;
14
13
  const { isMobile } = useViewportType();
15
- const { maskedValue } = useMaskedValue({
16
- currentValue: value,
17
- mask,
18
- onChangeValue: onChange,
19
- });
20
14
  const isTitleShow = isMobile && typeof label === 'string';
21
15
  const handleOpen = (event) => {
22
16
  onOpen?.();
23
17
  open(event);
24
18
  };
25
- const handleClose = () => {
26
- onBlur?.();
27
- onClose?.();
28
- close();
29
- };
30
19
  const handleDayPick = (date) => {
31
20
  if (isMobile) {
32
21
  setSelectedDate(date);
@@ -39,7 +28,7 @@ export const useLogic = ({ label, value, maxDate = DEFAULT_MAX_DATE, minDate = D
39
28
  onChange?.(date);
40
29
  }
41
30
  };
42
- const { onAccept, inputProps: calculatedInputProps, pickerProps, } = useDatePickerOptions({
31
+ const { onAccept, inputProps: calculatedInputProps, pickerProps, onMaskedValueBlur, } = useDatePickerOptions({
43
32
  currentValue: value,
44
33
  maxDate,
45
34
  minDate,
@@ -47,6 +36,22 @@ export const useLogic = ({ label, value, maxDate = DEFAULT_MAX_DATE, minDate = D
47
36
  onDatePick: handleDayPick,
48
37
  onChange: handleChange,
49
38
  });
39
+ /** Blur: неполный ввод -Invalid Date в onChange (при isMobile onChange не шлём), потом внешний onBlur. */
40
+ const flushMaskedValueAndCallOnBlur = () => {
41
+ onMaskedValueBlur();
42
+ onBlur?.();
43
+ };
44
+ function handleClose() {
45
+ flushMaskedValueAndCallOnBlur();
46
+ onClose?.();
47
+ close();
48
+ }
49
+ const handleInputBlur = () => {
50
+ if (isOpen) {
51
+ return;
52
+ }
53
+ flushMaskedValueAndCallOnBlur();
54
+ };
50
55
  const handleConfirm = () => {
51
56
  onChange?.(selectedDate);
52
57
  handleClose();
@@ -58,7 +63,8 @@ export const useLogic = ({ label, value, maxDate = DEFAULT_MAX_DATE, minDate = D
58
63
  title: isTitleShow ? label : undefined,
59
64
  };
60
65
  const DatePickerInputProps = {
61
- value: isMobile ? maskedValue : calculatedInputProps.value,
66
+ value: calculatedInputProps.value,
67
+ onBlur: handleInputBlur,
62
68
  };
63
69
  const confirmButtonProps = {
64
70
  onClick: handleConfirm,
@@ -7,12 +7,14 @@ export declare const useLogic: ({ value, minDate, maxDate, mask, onChange, onOpe
7
7
  ref: import("react").RefObject<HTMLInputElement | null>;
8
8
  value: string;
9
9
  onAccept: (_: string, maskRef: IMask.InputMask<IMask.AnyMaskedOptions>) => void;
10
+ onBlur: () => void;
10
11
  onClick: (e: SyntheticEvent) => void;
11
12
  };
12
13
  endDatePickerInputProps: {
13
14
  ref: import("react").RefObject<HTMLInputElement | null>;
14
15
  value: string;
15
16
  onAccept: (_: string, maskRef: IMask.InputMask<IMask.AnyMaskedOptions>) => void;
17
+ onBlur: () => void;
16
18
  onClick: (e: SyntheticEvent) => void;
17
19
  };
18
20
  popoverHoveredContextProviderProps: {
@@ -2,8 +2,9 @@ import { useEffect, useRef, useState, } from 'react';
2
2
  import { DEFAULT_DATE_MASK, DEFAULT_MAX_DATE, DEFAULT_MIN_DATE, useMaskedValue, useSelectedBaseDate, } from '../../DatePicker';
3
3
  import { useForwardedRef } from '../../useForwardedRef';
4
4
  import { usePopover } from '../../usePopover';
5
+ import { isMaskedDateSyntacticallyComplete } from '../../utils/date';
5
6
  import { useBaseRangeDates } from './hooks';
6
- import { getBoundaryDate, isMaskedDateSyntacticallyComplete } from './utils';
7
+ import { getBoundaryDate } from './utils';
7
8
  export const useLogic = ({ value, minDate = DEFAULT_MIN_DATE, maxDate = DEFAULT_MAX_DATE, mask = DEFAULT_DATE_MASK, onChange, onOpen, onClose, onBlur, }, forwardedRef) => {
8
9
  const ref = useForwardedRef(forwardedRef);
9
10
  const startInputRef = useRef(null);
@@ -65,12 +66,12 @@ export const useLogic = ({ value, minDate = DEFAULT_MIN_DATE, maxDate = DEFAULT_
65
66
  }
66
67
  onChange?.({ ...value, end: endDateValue });
67
68
  };
68
- const { maskedValue: startMaskedValue, onMaskedValueChange: onMaskedStartValueChange, onMaskedDateChange: onMaskedStartDateChange, } = useMaskedValue({
69
+ const { maskedValue: startMaskedValue, onMaskedValueChange: onMaskedStartValueChange, onMaskedValueBlur: onMaskedStartValueBlur, onMaskedDateChange: onMaskedStartDateChange, } = useMaskedValue({
69
70
  currentValue: value?.start,
70
71
  mask,
71
72
  onChangeValue: handleChangeStartDate,
72
73
  });
73
- const { maskedValue: endMaskedValue, onMaskedValueChange: onMaskedEndValueChange, onMaskedDateChange: onMaskedEndDateChange, } = useMaskedValue({
74
+ const { maskedValue: endMaskedValue, onMaskedValueChange: onMaskedEndValueChange, onMaskedValueBlur: onMaskedEndValueBlur, onMaskedDateChange: onMaskedEndDateChange, } = useMaskedValue({
74
75
  currentValue: value?.end,
75
76
  mask,
76
77
  onChangeValue: handleChangeEndDate,
@@ -79,11 +80,32 @@ export const useLogic = ({ value, minDate = DEFAULT_MIN_DATE, maxDate = DEFAULT_
79
80
  onOpen?.();
80
81
  open(event);
81
82
  };
83
+ const flushActiveMaskedValueOnClose = () => {
84
+ if (activeInput === 'startDate') {
85
+ onMaskedStartValueBlur();
86
+ }
87
+ if (activeInput === 'endDate') {
88
+ onMaskedEndValueBlur();
89
+ }
90
+ };
82
91
  const handleClose = () => {
92
+ flushActiveMaskedValueOnClose();
83
93
  onBlur?.();
84
94
  onClose?.();
85
95
  close();
86
96
  };
97
+ const handleStartInputBlur = () => {
98
+ if (isOpen) {
99
+ return;
100
+ }
101
+ onMaskedStartValueBlur();
102
+ };
103
+ const handleEndInputBlur = () => {
104
+ if (isOpen) {
105
+ return;
106
+ }
107
+ onMaskedEndValueBlur();
108
+ };
87
109
  const handleClickStartInput = (e) => {
88
110
  setActiveInput('startDate');
89
111
  startInputRef.current?.focus();
@@ -138,12 +160,14 @@ export const useLogic = ({ value, minDate = DEFAULT_MIN_DATE, maxDate = DEFAULT_
138
160
  ref: startInputRef,
139
161
  value: startMaskedValue,
140
162
  onAccept: handleAcceptStart,
163
+ onBlur: handleStartInputBlur,
141
164
  onClick: handleClickStartInput,
142
165
  },
143
166
  endDatePickerInputProps: {
144
167
  ref: endInputRef,
145
168
  value: endMaskedValue,
146
169
  onAccept: handleAcceptEnd,
170
+ onBlur: handleEndInputBlur,
147
171
  onClick: handleClickEndInput,
148
172
  },
149
173
  popoverHoveredContextProviderProps: {
@@ -1,2 +1 @@
1
1
  export * from './getBoundaryDate';
2
- export * from './isMaskedDateSyntacticallyComplete';
@@ -1,2 +1 @@
1
1
  export * from './getBoundaryDate';
2
- export * from './isMaskedDateSyntacticallyComplete';
@@ -1,3 +1,4 @@
1
1
  export * from './formatDate';
2
+ export * from './isMaskedDateSyntacticallyComplete';
2
3
  export * from './maskDate';
3
4
  export * from './parseDate';
@@ -1,3 +1,4 @@
1
1
  export * from './formatDate';
2
+ export * from './isMaskedDateSyntacticallyComplete';
2
3
  export * from './maskDate';
3
4
  export * from './parseDate';
@@ -1,4 +1,4 @@
1
- import { type DateMask } from '../../../../utils/date/format';
1
+ import { type DateMask } from '../maskDate';
2
2
  /**
3
3
  * Все ли части строки по маске заполнены цифрами полностью (без промежуточных состояний IMask).
4
4
  */
@@ -1,5 +1,6 @@
1
1
  import { type DateMask } from '../maskDate';
2
2
  /**
3
3
  * утилита конвертации строковой даты созданной по маске обратно в Date
4
+ * Строгий разбор UTC через `dayjs` + `customParseFormat`; при невалидной строке — Invalid Date
4
5
  */
5
6
  export declare const parseDate: (date: string, mask: DateMask, separator?: string) => Date;
@@ -1,21 +1,50 @@
1
- import { buildIsoDate, } from '../../buildIsoDate';
1
+ import dayjs from 'dayjs';
2
+ import customParseFormat from 'dayjs/plugin/customParseFormat';
3
+ import utc from 'dayjs/plugin/utc';
2
4
  import { DateMaskElements } from '../maskDate';
3
- const orderMap = {
4
- [DateMaskElements.Year]: 'year',
5
- [DateMaskElements.Month]: 'month',
6
- [DateMaskElements.Day]: 'day',
7
- [DateMaskElements.Hour]: 'hour',
8
- [DateMaskElements.Minute]: 'minute',
9
- [DateMaskElements.Second]: 'second',
5
+ dayjs.extend(customParseFormat);
6
+ dayjs.extend(utc);
7
+ /**
8
+ * Токен маски UI → литерал формата `dayjs` (strict UTC).
9
+ * В маске час — `hh`, в дате используются сутки 0–23 как в `formatDate` (UTC), поэтому `HH`.
10
+ */
11
+ const maskElementToDayjs = {
12
+ [DateMaskElements.Day]: 'DD',
13
+ [DateMaskElements.Month]: 'MM',
14
+ [DateMaskElements.Year]: 'YYYY',
15
+ [DateMaskElements.Hour]: 'HH',
16
+ [DateMaskElements.Minute]: 'mm',
17
+ [DateMaskElements.Second]: 'ss',
18
+ };
19
+ const buildDayjsFormat = (mask, separator) => {
20
+ const parts = mask.split(separator);
21
+ const tokens = [];
22
+ for (const part of parts) {
23
+ const t = maskElementToDayjs[part];
24
+ if (t == null) {
25
+ return null;
26
+ }
27
+ tokens.push(t);
28
+ }
29
+ return tokens.join(separator);
10
30
  };
11
31
  /**
12
32
  * утилита конвертации строковой даты созданной по маске обратно в Date
33
+ * Строгий разбор UTC через `dayjs` + `customParseFormat`; при невалидной строке — Invalid Date
13
34
  */
14
35
  export const parseDate = (date, mask, separator = '.') => {
15
36
  const dateArr = date.split(separator);
16
- const options = { year: 1900 };
17
- mask.split('.').forEach((element, index) => {
18
- options[orderMap[element]] = parseInt(dateArr[index]);
19
- });
20
- return buildIsoDate(options);
37
+ const maskSplit = mask.split(separator);
38
+ if (dateArr.length !== maskSplit.length) {
39
+ return new Date(Number.NaN);
40
+ }
41
+ const format = buildDayjsFormat(mask, separator);
42
+ if (format == null) {
43
+ return new Date(Number.NaN);
44
+ }
45
+ const parsed = dayjs.utc(date, format, true);
46
+ if (!parsed.isValid()) {
47
+ return new Date(Number.NaN);
48
+ }
49
+ return parsed.toDate();
21
50
  };
@@ -30,6 +30,8 @@ type UseMaskedValueAndSelectedBaseDateReturn = {
30
30
  */
31
31
  value: string;
32
32
  };
33
+ /** При blur неполной даты - в onChange уходит Invalid Date (тот же стейт, что у маски). */
34
+ onMaskedValueBlur: () => void;
33
35
  };
34
36
  /**
35
37
  * хук объединяющий повторяющуюся логику в работе DatePicker и RangeDatePicker:
@@ -15,7 +15,7 @@ const useSelectedBaseDate_1 = require("../useSelectedBaseDate");
15
15
  */
16
16
  const useDatePickerOptions = ({ onChange, mask, currentValue, minDate, maxDate, monthOffset, onDatePick, }) => {
17
17
  const baseDate = (0, useBaseDateInRange_1.useBaseDateInRange)({ minDate, maxDate, monthOffset });
18
- const { maskedValue, onMaskedValueChange, onMaskedDateChange } = (0, useMaskedValue_1.useMaskedValue)({
18
+ const { maskedValue, onMaskedValueChange, onMaskedValueBlur, onMaskedDateChange, } = (0, useMaskedValue_1.useMaskedValue)({
19
19
  currentValue,
20
20
  mask,
21
21
  onChangeValue: onChange,
@@ -42,6 +42,7 @@ const useDatePickerOptions = ({ onChange, mask, currentValue, minDate, maxDate,
42
42
  onChange: handleDatePick,
43
43
  date: selectedBaseDate || baseDate,
44
44
  },
45
+ onMaskedValueBlur,
45
46
  };
46
47
  };
47
48
  exports.useDatePickerOptions = useDatePickerOptions;
@@ -19,6 +19,10 @@ type Returned = {
19
19
  * Изменение maskedValue
20
20
  * */
21
21
  onMaskedValueChange: (value: string) => void;
22
+ /**
23
+ * Реакция на blur maskedValue
24
+ * */
25
+ onMaskedValueBlur: () => void;
22
26
  };
23
27
  /**
24
28
  * Хук для управления значением для MaskField
@@ -9,19 +9,43 @@ const date_1 = require("../../../utils/date");
9
9
  */
10
10
  const useMaskedValue = ({ currentValue, mask, onChangeValue, }) => {
11
11
  const [maskedValue, setMaskedValue] = (0, react_1.useState)(() => currentValue ? (0, date_1.formatDate)(currentValue, mask) : '');
12
+ const maskedValueRef = (0, react_1.useRef)(maskedValue);
13
+ /**
14
+ * Ввод в маску: синхронизируем локальное значение и ref (ref нужен на blur и после выбора даты из календаря,
15
+ * чтобы не читать устаревший state).
16
+ * `onChangeValue` вызываем только при пустой строке (`null`) или когда маска синтаксически полная —
17
+ * иначе partial не уходит наружу до завершения ввода или до blur (см. `handleMaskedValueBlur`).
18
+ */
12
19
  const handleMaskedValueChange = (value) => {
13
20
  setMaskedValue(value);
14
- // При отсутствии value указываем null, так как если задать undefined, то отобразится defaultValue при наличии
21
+ maskedValueRef.current = value;
15
22
  if (!value) {
16
23
  onChangeValue?.(null, value);
24
+ return;
25
+ }
26
+ if (!(0, date_1.isMaskedDateSyntacticallyComplete)(value, mask)) {
27
+ return;
17
28
  }
18
- else {
19
- onChangeValue?.((0, date_1.parseDate)(value, mask), value);
29
+ onChangeValue?.((0, date_1.parseDate)(value, mask), value);
30
+ };
31
+ /**
32
+ * Blur поля: если осталась непустая неполная маска — один раз отдаём `Invalid Date` в `onChangeValue`,
33
+ * чтобы формы могли показать ошибку. Пустая строка и полная маска не дублируют уже отправленный `onChange`.
34
+ */
35
+ const handleMaskedValueBlur = () => {
36
+ const currentMaskedValue = maskedValueRef.current;
37
+ if (!currentMaskedValue) {
38
+ return;
39
+ }
40
+ if ((0, date_1.isMaskedDateSyntacticallyComplete)(currentMaskedValue, mask)) {
41
+ return;
20
42
  }
43
+ onChangeValue?.(new Date(Number.NaN), currentMaskedValue);
21
44
  };
22
45
  const handleChangeMaskedDate = (date) => {
23
46
  const formatted = (0, date_1.formatDate)(date, mask);
24
47
  setMaskedValue(formatted);
48
+ maskedValueRef.current = formatted;
25
49
  onChangeValue?.(date, formatted);
26
50
  };
27
51
  // здесь происходит реакция на изменение value из вне (управляемый компонент)
@@ -29,6 +53,7 @@ const useMaskedValue = ({ currentValue, mask, onChangeValue, }) => {
29
53
  // если новое значение пустое, то сбрасываем значение MaskField
30
54
  if (!currentValue) {
31
55
  setMaskedValue('');
56
+ maskedValueRef.current = '';
32
57
  return;
33
58
  }
34
59
  // здесь обрабатывается сценарий, когда в инпут вводится невалидная дата и при этом currentValue становится Invalid Date
@@ -36,16 +61,20 @@ const useMaskedValue = ({ currentValue, mask, onChangeValue, }) => {
36
61
  if (!(0, isDate_1.isDate)(currentValue) && maskedValue) {
37
62
  return;
38
63
  }
39
- // проверяем равны ли даты
40
- const isEqualValueAndMaskedDate = currentValue.getTime() === (0, date_1.parseDate)(maskedValue, mask).getTime();
64
+ // сравнение только при полной маске, иначе getTime() от parseDate может быть NaN
65
+ const isEqualValueAndMaskedDate = (0, date_1.isMaskedDateSyntacticallyComplete)(maskedValue, mask) &&
66
+ currentValue.getTime() === (0, date_1.parseDate)(maskedValue, mask).getTime();
41
67
  // если даты не равны, то значит изменился currentValue из вне и надо синхронизировать maskedValue
42
68
  if (!isEqualValueAndMaskedDate) {
43
- setMaskedValue((0, date_1.formatDate)(currentValue, mask));
69
+ const formatted = (0, date_1.formatDate)(currentValue, mask);
70
+ setMaskedValue(formatted);
71
+ maskedValueRef.current = formatted;
44
72
  }
45
73
  }, [currentValue]);
46
74
  return {
47
75
  maskedValue,
48
76
  onMaskedValueChange: handleMaskedValueChange,
77
+ onMaskedValueBlur: handleMaskedValueBlur,
49
78
  onMaskedDateChange: handleChangeMaskedDate,
50
79
  };
51
80
  };
@@ -13,6 +13,7 @@ export declare const useLogic: ({ label, value, maxDate, minDate, mask, onOpen,
13
13
  };
14
14
  DatePickerInputProps: {
15
15
  value: string;
16
+ onBlur: () => void;
16
17
  };
17
18
  onAccept: ((value: string, maskRef: import("imask").default.InputMask<import("imask").default.AnyMaskedOptions>, e?: InputEvent | undefined, onChange?: ((changeValue: string) => void) | undefined) => void) | undefined;
18
19
  pickerProps: import("../types").PickerProps;
@@ -7,7 +7,6 @@ const usePopover_1 = require("../../usePopover");
7
7
  const useViewportType_1 = require("../../useViewportType");
8
8
  const constants_1 = require("../constants");
9
9
  const useDatePickerOptions_1 = require("../hooks/useDatePickerOptions");
10
- const useMaskedValue_1 = require("../hooks/useMaskedValue");
11
10
  const MinMaxDateContext_1 = require("../MinMaxDateContext");
12
11
  const useLogic = ({ label, value, maxDate = MinMaxDateContext_1.DEFAULT_MAX_DATE, minDate = MinMaxDateContext_1.DEFAULT_MIN_DATE, mask = constants_1.DEFAULT_DATE_MASK, onOpen, onClose, onBlur, onChange, forwardedRef, }) => {
13
12
  const ref = (0, useForwardedRef_1.useForwardedRef)(forwardedRef);
@@ -15,21 +14,11 @@ const useLogic = ({ label, value, maxDate = MinMaxDateContext_1.DEFAULT_MAX_DATE
15
14
  const { isOpen, actions } = (0, usePopover_1.usePopover)();
16
15
  const { open, close } = actions;
17
16
  const { isMobile } = (0, useViewportType_1.useViewportType)();
18
- const { maskedValue } = (0, useMaskedValue_1.useMaskedValue)({
19
- currentValue: value,
20
- mask,
21
- onChangeValue: onChange,
22
- });
23
17
  const isTitleShow = isMobile && typeof label === 'string';
24
18
  const handleOpen = (event) => {
25
19
  onOpen?.();
26
20
  open(event);
27
21
  };
28
- const handleClose = () => {
29
- onBlur?.();
30
- onClose?.();
31
- close();
32
- };
33
22
  const handleDayPick = (date) => {
34
23
  if (isMobile) {
35
24
  setSelectedDate(date);
@@ -42,7 +31,7 @@ const useLogic = ({ label, value, maxDate = MinMaxDateContext_1.DEFAULT_MAX_DATE
42
31
  onChange?.(date);
43
32
  }
44
33
  };
45
- const { onAccept, inputProps: calculatedInputProps, pickerProps, } = (0, useDatePickerOptions_1.useDatePickerOptions)({
34
+ const { onAccept, inputProps: calculatedInputProps, pickerProps, onMaskedValueBlur, } = (0, useDatePickerOptions_1.useDatePickerOptions)({
46
35
  currentValue: value,
47
36
  maxDate,
48
37
  minDate,
@@ -50,6 +39,22 @@ const useLogic = ({ label, value, maxDate = MinMaxDateContext_1.DEFAULT_MAX_DATE
50
39
  onDatePick: handleDayPick,
51
40
  onChange: handleChange,
52
41
  });
42
+ /** Blur: неполный ввод -Invalid Date в onChange (при isMobile onChange не шлём), потом внешний onBlur. */
43
+ const flushMaskedValueAndCallOnBlur = () => {
44
+ onMaskedValueBlur();
45
+ onBlur?.();
46
+ };
47
+ function handleClose() {
48
+ flushMaskedValueAndCallOnBlur();
49
+ onClose?.();
50
+ close();
51
+ }
52
+ const handleInputBlur = () => {
53
+ if (isOpen) {
54
+ return;
55
+ }
56
+ flushMaskedValueAndCallOnBlur();
57
+ };
53
58
  const handleConfirm = () => {
54
59
  onChange?.(selectedDate);
55
60
  handleClose();
@@ -61,7 +66,8 @@ const useLogic = ({ label, value, maxDate = MinMaxDateContext_1.DEFAULT_MAX_DATE
61
66
  title: isTitleShow ? label : undefined,
62
67
  };
63
68
  const DatePickerInputProps = {
64
- value: isMobile ? maskedValue : calculatedInputProps.value,
69
+ value: calculatedInputProps.value,
70
+ onBlur: handleInputBlur,
65
71
  };
66
72
  const confirmButtonProps = {
67
73
  onClick: handleConfirm,
@@ -7,12 +7,14 @@ export declare const useLogic: ({ value, minDate, maxDate, mask, onChange, onOpe
7
7
  ref: import("react").RefObject<HTMLInputElement | null>;
8
8
  value: string;
9
9
  onAccept: (_: string, maskRef: IMask.InputMask<IMask.AnyMaskedOptions>) => void;
10
+ onBlur: () => void;
10
11
  onClick: (e: SyntheticEvent) => void;
11
12
  };
12
13
  endDatePickerInputProps: {
13
14
  ref: import("react").RefObject<HTMLInputElement | null>;
14
15
  value: string;
15
16
  onAccept: (_: string, maskRef: IMask.InputMask<IMask.AnyMaskedOptions>) => void;
17
+ onBlur: () => void;
16
18
  onClick: (e: SyntheticEvent) => void;
17
19
  };
18
20
  popoverHoveredContextProviderProps: {
@@ -5,6 +5,7 @@ const react_1 = require("react");
5
5
  const DatePicker_1 = require("../../DatePicker");
6
6
  const useForwardedRef_1 = require("../../useForwardedRef");
7
7
  const usePopover_1 = require("../../usePopover");
8
+ const date_1 = require("../../utils/date");
8
9
  const hooks_1 = require("./hooks");
9
10
  const utils_1 = require("./utils");
10
11
  const useLogic = ({ value, minDate = DatePicker_1.DEFAULT_MIN_DATE, maxDate = DatePicker_1.DEFAULT_MAX_DATE, mask = DatePicker_1.DEFAULT_DATE_MASK, onChange, onOpen, onClose, onBlur, }, forwardedRef) => {
@@ -45,7 +46,7 @@ const useLogic = ({ value, minDate = DatePicker_1.DEFAULT_MIN_DATE, maxDate = Da
45
46
  const handleChangeStartDate = (startDateValue, startMaskString) => {
46
47
  const isStartComplete = startDateValue != null &&
47
48
  startMaskString != null &&
48
- (0, utils_1.isMaskedDateSyntacticallyComplete)(startMaskString, mask);
49
+ (0, date_1.isMaskedDateSyntacticallyComplete)(startMaskString, mask);
49
50
  if (isStartComplete &&
50
51
  value?.end &&
51
52
  startDateValue &&
@@ -58,7 +59,7 @@ const useLogic = ({ value, minDate = DatePicker_1.DEFAULT_MIN_DATE, maxDate = Da
58
59
  const handleChangeEndDate = (endDateValue, endMaskString) => {
59
60
  const isEndComplete = endDateValue != null &&
60
61
  endMaskString != null &&
61
- (0, utils_1.isMaskedDateSyntacticallyComplete)(endMaskString, mask);
62
+ (0, date_1.isMaskedDateSyntacticallyComplete)(endMaskString, mask);
62
63
  if (isEndComplete &&
63
64
  value?.start &&
64
65
  endDateValue &&
@@ -68,12 +69,12 @@ const useLogic = ({ value, minDate = DatePicker_1.DEFAULT_MIN_DATE, maxDate = Da
68
69
  }
69
70
  onChange?.({ ...value, end: endDateValue });
70
71
  };
71
- const { maskedValue: startMaskedValue, onMaskedValueChange: onMaskedStartValueChange, onMaskedDateChange: onMaskedStartDateChange, } = (0, DatePicker_1.useMaskedValue)({
72
+ const { maskedValue: startMaskedValue, onMaskedValueChange: onMaskedStartValueChange, onMaskedValueBlur: onMaskedStartValueBlur, onMaskedDateChange: onMaskedStartDateChange, } = (0, DatePicker_1.useMaskedValue)({
72
73
  currentValue: value?.start,
73
74
  mask,
74
75
  onChangeValue: handleChangeStartDate,
75
76
  });
76
- const { maskedValue: endMaskedValue, onMaskedValueChange: onMaskedEndValueChange, onMaskedDateChange: onMaskedEndDateChange, } = (0, DatePicker_1.useMaskedValue)({
77
+ const { maskedValue: endMaskedValue, onMaskedValueChange: onMaskedEndValueChange, onMaskedValueBlur: onMaskedEndValueBlur, onMaskedDateChange: onMaskedEndDateChange, } = (0, DatePicker_1.useMaskedValue)({
77
78
  currentValue: value?.end,
78
79
  mask,
79
80
  onChangeValue: handleChangeEndDate,
@@ -82,11 +83,32 @@ const useLogic = ({ value, minDate = DatePicker_1.DEFAULT_MIN_DATE, maxDate = Da
82
83
  onOpen?.();
83
84
  open(event);
84
85
  };
86
+ const flushActiveMaskedValueOnClose = () => {
87
+ if (activeInput === 'startDate') {
88
+ onMaskedStartValueBlur();
89
+ }
90
+ if (activeInput === 'endDate') {
91
+ onMaskedEndValueBlur();
92
+ }
93
+ };
85
94
  const handleClose = () => {
95
+ flushActiveMaskedValueOnClose();
86
96
  onBlur?.();
87
97
  onClose?.();
88
98
  close();
89
99
  };
100
+ const handleStartInputBlur = () => {
101
+ if (isOpen) {
102
+ return;
103
+ }
104
+ onMaskedStartValueBlur();
105
+ };
106
+ const handleEndInputBlur = () => {
107
+ if (isOpen) {
108
+ return;
109
+ }
110
+ onMaskedEndValueBlur();
111
+ };
90
112
  const handleClickStartInput = (e) => {
91
113
  setActiveInput('startDate');
92
114
  startInputRef.current?.focus();
@@ -141,12 +163,14 @@ const useLogic = ({ value, minDate = DatePicker_1.DEFAULT_MIN_DATE, maxDate = Da
141
163
  ref: startInputRef,
142
164
  value: startMaskedValue,
143
165
  onAccept: handleAcceptStart,
166
+ onBlur: handleStartInputBlur,
144
167
  onClick: handleClickStartInput,
145
168
  },
146
169
  endDatePickerInputProps: {
147
170
  ref: endInputRef,
148
171
  value: endMaskedValue,
149
172
  onAccept: handleAcceptEnd,
173
+ onBlur: handleEndInputBlur,
150
174
  onClick: handleClickEndInput,
151
175
  },
152
176
  popoverHoveredContextProviderProps: {
@@ -1,2 +1 @@
1
1
  export * from './getBoundaryDate';
2
- export * from './isMaskedDateSyntacticallyComplete';
@@ -15,4 +15,3 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./getBoundaryDate"), exports);
18
- __exportStar(require("./isMaskedDateSyntacticallyComplete"), exports);
@@ -1,3 +1,4 @@
1
1
  export * from './formatDate';
2
+ export * from './isMaskedDateSyntacticallyComplete';
2
3
  export * from './maskDate';
3
4
  export * from './parseDate';
@@ -15,5 +15,6 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./formatDate"), exports);
18
+ __exportStar(require("./isMaskedDateSyntacticallyComplete"), exports);
18
19
  __exportStar(require("./maskDate"), exports);
19
20
  __exportStar(require("./parseDate"), exports);
@@ -1,4 +1,4 @@
1
- import { type DateMask } from '../../../../utils/date/format';
1
+ import { type DateMask } from '../maskDate';
2
2
  /**
3
3
  * Все ли части строки по маске заполнены цифрами полностью (без промежуточных состояний IMask).
4
4
  */
@@ -1,5 +1,6 @@
1
1
  import { type DateMask } from '../maskDate';
2
2
  /**
3
3
  * утилита конвертации строковой даты созданной по маске обратно в Date
4
+ * Строгий разбор UTC через `dayjs` + `customParseFormat`; при невалидной строке — Invalid Date
4
5
  */
5
6
  export declare const parseDate: (date: string, mask: DateMask, separator?: string) => Date;
@@ -1,25 +1,57 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.parseDate = void 0;
4
- const buildIsoDate_1 = require("../../buildIsoDate");
7
+ const dayjs_1 = __importDefault(require("dayjs"));
8
+ const customParseFormat_1 = __importDefault(require("dayjs/plugin/customParseFormat"));
9
+ const utc_1 = __importDefault(require("dayjs/plugin/utc"));
5
10
  const maskDate_1 = require("../maskDate");
6
- const orderMap = {
7
- [maskDate_1.DateMaskElements.Year]: 'year',
8
- [maskDate_1.DateMaskElements.Month]: 'month',
9
- [maskDate_1.DateMaskElements.Day]: 'day',
10
- [maskDate_1.DateMaskElements.Hour]: 'hour',
11
- [maskDate_1.DateMaskElements.Minute]: 'minute',
12
- [maskDate_1.DateMaskElements.Second]: 'second',
11
+ dayjs_1.default.extend(customParseFormat_1.default);
12
+ dayjs_1.default.extend(utc_1.default);
13
+ /**
14
+ * Токен маски UI → литерал формата `dayjs` (strict UTC).
15
+ * В маске час — `hh`, в дате используются сутки 0–23 как в `formatDate` (UTC), поэтому `HH`.
16
+ */
17
+ const maskElementToDayjs = {
18
+ [maskDate_1.DateMaskElements.Day]: 'DD',
19
+ [maskDate_1.DateMaskElements.Month]: 'MM',
20
+ [maskDate_1.DateMaskElements.Year]: 'YYYY',
21
+ [maskDate_1.DateMaskElements.Hour]: 'HH',
22
+ [maskDate_1.DateMaskElements.Minute]: 'mm',
23
+ [maskDate_1.DateMaskElements.Second]: 'ss',
24
+ };
25
+ const buildDayjsFormat = (mask, separator) => {
26
+ const parts = mask.split(separator);
27
+ const tokens = [];
28
+ for (const part of parts) {
29
+ const t = maskElementToDayjs[part];
30
+ if (t == null) {
31
+ return null;
32
+ }
33
+ tokens.push(t);
34
+ }
35
+ return tokens.join(separator);
13
36
  };
14
37
  /**
15
38
  * утилита конвертации строковой даты созданной по маске обратно в Date
39
+ * Строгий разбор UTC через `dayjs` + `customParseFormat`; при невалидной строке — Invalid Date
16
40
  */
17
41
  const parseDate = (date, mask, separator = '.') => {
18
42
  const dateArr = date.split(separator);
19
- const options = { year: 1900 };
20
- mask.split('.').forEach((element, index) => {
21
- options[orderMap[element]] = parseInt(dateArr[index]);
22
- });
23
- return (0, buildIsoDate_1.buildIsoDate)(options);
43
+ const maskSplit = mask.split(separator);
44
+ if (dateArr.length !== maskSplit.length) {
45
+ return new Date(Number.NaN);
46
+ }
47
+ const format = buildDayjsFormat(mask, separator);
48
+ if (format == null) {
49
+ return new Date(Number.NaN);
50
+ }
51
+ const parsed = dayjs_1.default.utc(date, format, true);
52
+ if (!parsed.isValid()) {
53
+ return new Date(Number.NaN);
54
+ }
55
+ return parsed.toDate();
24
56
  };
25
57
  exports.parseDate = parseDate;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astral/ui",
3
- "version": "4.52.0",
3
+ "version": "4.52.1",
4
4
  "browser": "./index.js",
5
5
  "main": "./node/index.js",
6
6
  "dependencies": {