@gravityforms/components
Version:
UI components for use in Gravity Forms development. Both React and vanilla js flavors.
297 lines (271 loc) • 8.6 kB
JavaScript
/**
* @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 };