modules_DatePicker_utils_index.js

/**
 * @constant DEFAULT_DATE_FORMAT
 * @description Default date format used by the DatePicker utilities.
 *
 * @since 5.8.6
 *
 * @type {string}
 */
const DEFAULT_DATE_FORMAT = 'MM/dd/yyyy';

const TOKEN_REGEX = /(Y+|y+|M+|m+|D+|d+)/g;

/**
 * @function isValidDate
 * @description Determine whether a value is a valid Date instance.
 *
 * @since 5.8.6
 *
 * @param {*} value The value to test.
 *
 * @return {boolean} True when the value is a valid date.
 */
const isValidDate = ( value ) => value instanceof Date && ! Number.isNaN( value.getTime() );

/**
 * @function replaceTokens
 * @description Replace all token occurrences in a pattern with provided values.
 *
 * @since 5.8.6
 *
 * @param {string} pattern      The format pattern to transform.
 * @param {Array}  replacements Array of replacement definitions `{ tokens, value }`.
 *
 * @return {string} The formatted string with tokens substituted.
 */
const replaceTokens = ( pattern, replacements ) => (
	replacements.reduce( ( formatted, { tokens, value } ) => (
		tokens.reduce( ( acc, token ) => acc.replace( new RegExp( token, 'g' ), value ), formatted )
	), pattern )
);

/**
 * @function getSegmentsFromFormat
 * @description Tokenize a format string into literal and token segments.
 *
 * @since 5.8.6
 *
 * @param {string} format The format template to parse.
 *
 * @return {Array} Ordered list of segment descriptors.
 */
const getSegmentsFromFormat = ( format ) => {
	const segments = [];
	let lastIndex = 0;
	let match;
	while ( ( match = TOKEN_REGEX.exec( format ) ) ) {
		if ( match.index > lastIndex ) {
			segments.push( {
				type: 'literal',
				value: format.slice( lastIndex, match.index ),
			} );
		}
		segments.push( {
			type: 'token',
			value: match[ 0 ],
			char: match[ 0 ][ 0 ],
			length: match[ 0 ].length,
		} );
		lastIndex = match.index + match[ 0 ].length;
	}
	if ( lastIndex < format.length ) {
		segments.push( {
			type: 'literal',
			value: format.slice( lastIndex ),
		} );
	}
	return segments;
};

/**
 * @function convertTwoDigitYear
 * @description Convert a two-digit year into a four-digit year using a mid-century pivot.
 *
 * @since 5.8.6
 *
 * @param {number} value The two-digit year.
 *
 * @return {number} The expanded four-digit year.
 */
const convertTwoDigitYear = ( value ) => {
	const pivot = 50;
	return value >= 0 && value < pivot ? 2000 + value : 1900 + value;
};

/**
 * @function formatDateWithPattern
 * @description Format a date according to a tokenized pattern.
 *
 * @since 5.8.6
 *
 * @param {string} pattern The desired output pattern.
 * @param {Date}   date    The date to format.
 *
 * @return {string} The formatted date string.
 */
export const formatDateWithPattern = ( pattern, date ) => {
	if ( ! isValidDate( date ) ) {
		return '';
	}
	const effectivePattern = pattern || DEFAULT_DATE_FORMAT;
	const yearFull = String( date.getFullYear() );
	const replacements = [
		{ tokens: [ 'YYYY', 'yyyy' ], value: yearFull },
		{ tokens: [ 'YY', 'yy' ], value: yearFull.slice( -2 ) },
		{ tokens: [ 'MM', 'mm' ], value: String( date.getMonth() + 1 ).padStart( 2, '0' ) },
		{ tokens: [ 'DD', 'dd' ], value: String( date.getDate() ).padStart( 2, '0' ) },
	];
	return replaceTokens( effectivePattern, replacements );
};

/**
 * @function formatValueWithFormat
 * @description Normalize any supported value to a formatted string using a pattern or formatter function.
 *
 * @since 5.8.6
 *
 * @param {string|Function} format The format string or formatter function.
 * @param {*}               value  The value to format (Date, range, or primitive).
 *
 * @return {string} The formatted representation.
 */
export const formatValueWithFormat = ( format, value ) => {
	if ( value === null || typeof value === 'undefined' ) {
		return '';
	}
	if ( typeof format === 'function' ) {
		const formatted = format( value );
		return formatted === null || typeof formatted === 'undefined' ? '' : String( formatted );
	}
	if ( Array.isArray( value ) ) {
		return value
			.map( ( item ) => formatValueWithFormat( format, item ) )
			.filter( Boolean )
			.join( ' - ' );
	}
	if ( isValidDate( value ) ) {
		return formatDateWithPattern( format, value );
	}
	return String( value );
};

/**
 * @function maskDateInputValue
 * @description Apply a format-aware mask to user-entered date text.
 *
 * @since 5.8.6
 *
 * @param {string|Function} format          The active date format (string tokens only).
 * @param {string}          rawValue        The raw user input value.
 * @param {?number}         caretDigitIndex Optional number of digits before the caret. When provided, returns an object containing `{ value, caretPosition }`.
 *
 * @return {string|object} The masked input string or `{ value, caretPosition }` when caret tracking is requested.
 */
export const maskDateInputValue = ( format, rawValue, caretDigitIndex = null ) => {
	if ( typeof format !== 'string' ) {
		return caretDigitIndex === null
			? rawValue
			: { value: rawValue, caretPosition: rawValue.length };
	}
	const segments = getSegmentsFromFormat( format );
	const digitString = String( rawValue || '' ).replace( /\D/g, '' );
	let digitIndex = 0;
	let result = '';
	let previousTokenComplete = false;
	const trackCaret = typeof caretDigitIndex === 'number' && caretDigitIndex >= 0;
	let caretPosition = trackCaret && caretDigitIndex === 0 ? 0 : null;

	for ( const segment of segments ) {
		if ( segment.type === 'literal' ) {
			if ( result && previousTokenComplete ) {
				result += segment.value;
			}
			continue;
		}

		const requiredLength = segment.length;
		const availableDigits = digitString.length - digitIndex;
		if ( availableDigits <= 0 ) {
			previousTokenComplete = false;
			break;
		}
		const takeLength = Math.min( requiredLength, availableDigits );
		const segmentDigits = digitString.slice( digitIndex, digitIndex + takeLength );
		const digitsPriorToSegment = digitIndex;
		result += segmentDigits;

		if ( trackCaret && caretPosition === null ) {
			if ( caretDigitIndex <= digitsPriorToSegment ) {
				caretPosition = result.length - segmentDigits.length;
			} else if ( caretDigitIndex <= digitsPriorToSegment + segmentDigits.length ) {
				const withinSegment = Math.max( caretDigitIndex - digitsPriorToSegment, 0 );
				caretPosition = ( result.length - segmentDigits.length ) + withinSegment;
			}
		}

		digitIndex += takeLength;
		previousTokenComplete = takeLength === requiredLength;
		if ( takeLength < requiredLength ) {
			break;
		}
	}

	if ( trackCaret && caretPosition === null ) {
		caretPosition = result.length;
	}

	return trackCaret ? { value: result, caretPosition } : result;
};

/**
 * @function parseDateFromMaskedValue
 * @description Parse a masked date string into a Date instance when valid.
 *
 * @since 5.8.6
 *
 * @param {string|Function} format The format string describing the mask.
 * @param {string}          value  The masked input value.
 *
 * @return {?Date} Parsed date or null when invalid/incomplete.
 */
export const parseDateFromMaskedValue = ( format, value ) => {
	if ( typeof format !== 'string' ) {
		return null;
	}
	const segments = getSegmentsFromFormat( format );
	let cursor = 0;
	let year;
	let month;
	let day;

	for ( const segment of segments ) {
		if ( segment.type === 'literal' ) {
			const literal = segment.value;
			if ( value.length < cursor + literal.length ) {
				return null;
			}
			if ( value.slice( cursor, cursor + literal.length ) !== literal ) {
				return null;
			}
			cursor += literal.length;
			continue;
		}

		const expectedLength = segment.length;
		if ( value.length < cursor + expectedLength ) {
			return null;
		}

		const numericString = value.slice( cursor, cursor + expectedLength );
		if ( numericString.length < expectedLength || /\D/.test( numericString ) ) {
			return null;
		}

		const numericValue = parseInt( numericString, 10 );
		const tokenChar = segment.char.toUpperCase();
		if ( tokenChar === 'Y' ) {
			year = expectedLength <= 2 ? convertTwoDigitYear( numericValue ) : numericValue;
		} else if ( tokenChar === 'M' ) {
			month = numericValue;
		} else if ( tokenChar === 'D' ) {
			day = numericValue;
		}

		cursor += expectedLength;
	}

	if ( cursor !== value.length ) {
		return null;
	}

	if ( typeof year !== 'number' || typeof month !== 'number' || typeof day !== 'number' ) {
		return null;
	}

	const date = new Date( year, month - 1, day );
	if ( Number.isNaN( date.getTime() ) ) {
		return null;
	}
	if ( date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day ) {
		return null;
	}

	return date;
};

export { DEFAULT_DATE_FORMAT };