181 lines
5.5 KiB
JavaScript
181 lines
5.5 KiB
JavaScript
const DISPLAY_DATE_PATTERN = /^(\d{2})\.(\d{2})\.(\d{4})$/;
|
||
const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||
const boundDateInputs = new WeakSet();
|
||
|
||
export function formatDateInputValue(value) {
|
||
const text = String(value || '');
|
||
const isoMatch = text.match(ISO_DATE_PATTERN);
|
||
if (isoMatch) {
|
||
return `${isoMatch[3]}.${isoMatch[2]}.${isoMatch[1]}`;
|
||
}
|
||
|
||
const digits = text.replace(/\D/g, '').slice(0, 8);
|
||
if (digits.length <= 2) return digits;
|
||
if (digits.length <= 4) return `${digits.slice(0, 2)}.${digits.slice(2)}`;
|
||
return `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
|
||
}
|
||
|
||
export function dateInputValueToIso(value) {
|
||
const text = String(value || '').trim();
|
||
if (!text) return '';
|
||
|
||
const match = text.match(DISPLAY_DATE_PATTERN);
|
||
if (!match) {
|
||
throw new TypeError('Введите дату полностью в формате ДД.ММ.ГГГГ (год — 4 цифры)');
|
||
}
|
||
|
||
const [, dayText, monthText, yearText] = match;
|
||
const day = Number(dayText);
|
||
const month = Number(monthText);
|
||
const year = Number(yearText);
|
||
if (year < 1 || month < 1 || month > 12 || day < 1 || day > daysInMonth(month, year)) {
|
||
throw new TypeError('Введите существующую дату в формате ДД.ММ.ГГГГ');
|
||
}
|
||
|
||
return `${yearText}-${monthText}-${dayText}`;
|
||
}
|
||
|
||
export function isoDateToInputValue(value) {
|
||
const text = String(value || '').trim();
|
||
if (!text) return '';
|
||
|
||
const match = text.match(ISO_DATE_PATTERN);
|
||
if (!match) {
|
||
throw new TypeError('Ожидалась дата в формате ГГГГ-ММ-ДД');
|
||
}
|
||
|
||
const [, year, month, day] = match;
|
||
dateInputValueToIso(`${day}.${month}.${year}`);
|
||
return `${day}.${month}.${year}`;
|
||
}
|
||
|
||
export function bindDateInputMask(input) {
|
||
if (!input || boundDateInputs.has(input)) return;
|
||
boundDateInputs.add(input);
|
||
|
||
input.addEventListener('input', () => {
|
||
const rawValue = input.value;
|
||
const caret = input.selectionStart ?? rawValue.length;
|
||
const digitsBeforeCaret = rawValue.slice(0, caret).replace(/\D/g, '').length;
|
||
const caretWasAtEnd = caret === rawValue.length;
|
||
const formattedValue = formatDateInputValue(rawValue);
|
||
|
||
input.value = formattedValue;
|
||
clearDateInputError(input);
|
||
const nextCaret = caretPositionAfterDigits(formattedValue, digitsBeforeCaret, caretWasAtEnd);
|
||
input.setSelectionRange?.(nextCaret, nextCaret);
|
||
});
|
||
|
||
input.addEventListener('keydown', event => {
|
||
if (event.key === 'Backspace'
|
||
&& input.selectionStart === input.selectionEnd
|
||
&& input.selectionStart > 0
|
||
&& input.value[input.selectionStart - 1] === '.') {
|
||
event.preventDefault();
|
||
const nextCaret = input.selectionStart - 1;
|
||
input.setSelectionRange(nextCaret, nextCaret);
|
||
}
|
||
|
||
if (event.key === 'Delete'
|
||
&& input.selectionStart === input.selectionEnd
|
||
&& input.value[input.selectionStart] === '.') {
|
||
event.preventDefault();
|
||
const nextCaret = input.selectionStart + 1;
|
||
input.setSelectionRange(nextCaret, nextCaret);
|
||
}
|
||
});
|
||
|
||
input.addEventListener('blur', () => {
|
||
if (!input.value) {
|
||
clearDateInputError(input);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const isoValue = dateInputValueToIso(input.value);
|
||
input.value = isoDateToInputValue(isoValue);
|
||
clearDateInputError(input);
|
||
} catch (error) {
|
||
setDateInputError(input, error.message);
|
||
}
|
||
});
|
||
|
||
input.form?.addEventListener('reset', () => clearDateInputError(input));
|
||
}
|
||
|
||
export function readDateInputIso(input, label = 'Дата') {
|
||
try {
|
||
const value = dateInputValueToIso(input?.value);
|
||
clearDateInputError(input);
|
||
return value;
|
||
} catch (error) {
|
||
const message = `${label}: ${lowercaseFirst(error.message)}`;
|
||
setDateInputError(input, message);
|
||
input?.focus();
|
||
throw new Error(message);
|
||
}
|
||
}
|
||
|
||
export function setDateInputIso(input, value) {
|
||
if (!input) return;
|
||
input.value = isoDateToInputValue(value);
|
||
clearDateInputError(input);
|
||
}
|
||
|
||
function daysInMonth(month, year) {
|
||
const monthLengths = [
|
||
31,
|
||
isLeapYear(year) ? 29 : 28,
|
||
31,
|
||
30,
|
||
31,
|
||
30,
|
||
31,
|
||
31,
|
||
30,
|
||
31,
|
||
30,
|
||
31
|
||
];
|
||
return monthLengths[month - 1] || 0;
|
||
}
|
||
|
||
function isLeapYear(year) {
|
||
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
||
}
|
||
|
||
function caretPositionAfterDigits(value, digitCount, preferEnd) {
|
||
if (digitCount <= 0) return 0;
|
||
|
||
let seenDigits = 0;
|
||
for (let index = 0; index < value.length; index += 1) {
|
||
if (!/\d/.test(value[index])) continue;
|
||
seenDigits += 1;
|
||
if (seenDigits === digitCount) {
|
||
let position = index + 1;
|
||
if (preferEnd) {
|
||
while (value[position] === '.') position += 1;
|
||
}
|
||
return position;
|
||
}
|
||
}
|
||
return value.length;
|
||
}
|
||
|
||
function setDateInputError(input, message) {
|
||
if (!input) return;
|
||
input.setCustomValidity?.(message);
|
||
input.setAttribute?.('aria-invalid', 'true');
|
||
}
|
||
|
||
function clearDateInputError(input) {
|
||
if (!input) return;
|
||
input.setCustomValidity?.('');
|
||
input.removeAttribute?.('aria-invalid');
|
||
}
|
||
|
||
function lowercaseFirst(value) {
|
||
const text = String(value || '');
|
||
return text ? text[0].toLowerCase() + text.slice(1) : text;
|
||
}
|