import { React, PropTypes, classnames } from '@gravityforms/libraries';
import { spacerClasses } from '@gravityforms/utils';
import Input from '../../elements/Input';
import Calendar from '../Calendar';
import {
DEFAULT_DATE_FORMAT,
formatValueWithFormat,
maskDateInputValue,
parseDateFromMaskedValue,
} from './utils';
const { forwardRef, useState, useRef, useEffect, useCallback } = React;
/**
* @module DatePicker
* @description A composite date picker built from `Input` and `Calendar`. The calendar opens when the input receives focus and closes when focus is lost, with interaction detection to keep the calendar open during use.
*
* @since 5.7.0
*
* @param {object} props Component props.
* @param {object} props.calendarAttributes Custom props for the `Calendar` component.
* @param {object} props.customAttributes Custom attributes for the wrapper element.
* @param {string|Array|object} props.customClasses Custom classes for the wrapper element.
* @param {object} props.inputAttributes Custom props for the `Input` component.
* @param {string|Function} props.dateFormat Format for displaying selected dates in the input. Strings support `YYYY`/`yyyy`, `YY`/`yy`, `MM`/`mm`, and `DD`/`dd` tokens; provide a formatter function for custom logic.
* @param {Function} props.onChange Change handler when date changes. Receives `(value, meta, event)` where value is a Date (or `[start, end]`), meta contains `{ formattedValue, timestamp }`, and event is the triggering event when available.
* @param {string|number|Array|object} props.spacing Spacing for the wrapper component.
* @param {object|null} ref Forwarded ref.
*
* @return {JSX.Element} DatePicker component.
*
* @example
* import { useState } from 'react';
* import DatePicker from '@gravityforms/components/react/admin/modules/DatePicker';
*
* export default function CampaignDateField() {
* const [ date, setDate ] = useState( null );
* const [ formatted, setFormatted ] = useState( '' );
*
* return (
* <DatePicker
* dateFormat="yyyy-MM-dd"
* inputAttributes={{
* labelAttributes: { label: 'Launch Date' },
* helpTextAttributes: { content: 'Use the YYYY-MM-DD format.' },
* }}
* onChange={ ( value, meta ) => {
* setDate( value );
* setFormatted( meta.formattedValue );
* } }
* calendarAttributes={{
* calendarAttributes: { selectRange: false },
* }}
* />
* );
* }
*
*/
const DatePicker = forwardRef( ( {
calendarAttributes = {},
customAttributes = {},
customClasses = [],
dateFormat = 'MM/dd/yyyy',
inputAttributes = {},
onChange = () => {},
spacing = '',
}, ref ) => {
const [ isCalendarOpen, setIsCalendarOpen ] = useState( false );
const [ inputValue, setInputValue ] = useState( () => {
let initialValue;
if ( typeof inputAttributes.value !== 'undefined' ) {
initialValue = inputAttributes.value === null ? '' : String( inputAttributes.value );
} else {
initialValue = formatValueWithFormat( dateFormat || DEFAULT_DATE_FORMAT, calendarAttributes?.calendarAttributes?.value );
}
return maskDateInputValue( dateFormat, initialValue );
} );
const inputRef = useRef( null );
const inputElementRef = useRef( null );
const calendarRef = useRef( null );
const [ inputHasError, setInputHasError ] = useState( false );
useEffect( () => {
if ( typeof dateFormat === 'string' ) {
const initialParsedDate = parseDateFromMaskedValue( dateFormat, inputValue );
setInputHasError( inputValue !== '' && ! initialParsedDate );
} else {
setInputHasError( false );
}
// we intentionally run only once on mount for initial evaluation.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [] );
const formatSelectedValue = useCallback( ( value ) => formatValueWithFormat( dateFormat || DEFAULT_DATE_FORMAT, value ), [ dateFormat ] );
const getPrimaryDateFromValue = useCallback( ( value ) => {
if ( Array.isArray( value ) ) {
return value.find( ( item ) => item instanceof Date ) || null;
}
return value instanceof Date ? value : null;
}, [] );
const getTimestampFromValue = useCallback( ( value ) => {
if ( value instanceof Date ) {
return value.getTime();
}
if ( Array.isArray( value ) ) {
return value.map( ( item ) => ( item instanceof Date ? item.getTime() : null ) );
}
return value ? value : null;
}, [] );
const emitChange = useCallback( ( value, event ) => {
const formattedValue = formatSelectedValue( value );
const timestamp = getTimestampFromValue( value );
onChange( value, { formattedValue, timestamp }, event );
}, [ formatSelectedValue, getTimestampFromValue, onChange ] );
const {
onFocus: externalInputOnFocus,
onBlur: externalInputOnBlur,
onChange: externalInputOnChange,
value: externalInputValue,
borderStyle: externalInputBorderStyle,
placeholder: externalInputPlaceholder,
customAttributes: externalInputCustomAttributes = {},
...restInputAttributes
} = inputAttributes;
const {
calendarAttributes: externalCalendarAttributes = {},
customClasses: externalCalendarClasses = [],
onClose: externalCalendarOnClose,
onOpen: externalCalendarOnOpen,
...restCalendarAttributes
} = calendarAttributes;
const {
onChange: externalCalendarOnChange,
value: externalCalendarValue,
activeStartDate: externalCalendarActiveStartDate,
...restExternalCalendarAttributes
} = externalCalendarAttributes;
const initialSelectedValue = typeof externalCalendarValue !== 'undefined'
? externalCalendarValue
: calendarAttributes?.calendarAttributes?.value ?? null;
const selectedValueRef = useRef( initialSelectedValue );
useEffect( () => {
if ( typeof externalInputValue !== 'undefined' ) {
selectedValueRef.current = null;
const nextValue = externalInputValue === null ? '' : String( externalInputValue );
const maskedNextValue = maskDateInputValue( dateFormat, nextValue );
setInputValue( maskedNextValue );
if ( typeof dateFormat === 'string' ) {
const parsedDate = parseDateFromMaskedValue( dateFormat, maskedNextValue );
setInputHasError( maskedNextValue !== '' && ! parsedDate );
} else {
setInputHasError( false );
}
}
}, [ externalInputValue, dateFormat ] );
useEffect( () => {
if ( typeof externalCalendarValue !== 'undefined' ) {
selectedValueRef.current = externalCalendarValue;
setInputValue( formatSelectedValue( externalCalendarValue ) );
setInputHasError( false );
}
}, [ externalCalendarValue, formatSelectedValue ] );
useEffect( () => {
if ( selectedValueRef.current !== null && typeof selectedValueRef.current !== 'undefined' ) {
setInputValue( formatSelectedValue( selectedValueRef.current ) );
}
}, [ formatSelectedValue ] );
// Handle input focus - open calendar
const handleInputFocus = ( event ) => {
setIsCalendarOpen( true );
// Call original onFocus if provided
if ( externalInputOnFocus ) {
externalInputOnFocus( event );
}
};
// Handle input blur - close calendar instantly unless interacting with calendar
const handleInputBlur = ( event ) => {
// Only close if we're not actively interacting with calendar
if ( ! shouldIgnoreTarget( event.relatedTarget ) ) {
setIsCalendarOpen( false );
}
// Call original onBlur if provided
if ( externalInputOnBlur ) {
externalInputOnBlur( event );
}
};
const handleInputChange = ( value, event ) => {
const usesTokenFormat = typeof dateFormat === 'string';
let maskedValue;
let nextCaretPosition = null;
if ( usesTokenFormat && typeof event?.target?.selectionStart === 'number' ) {
const digitsBeforeCaret = value.slice( 0, event.target.selectionStart ).replace( /\D/g, '' ).length;
const maskResult = maskDateInputValue( dateFormat, value, digitsBeforeCaret );
maskedValue = maskResult.value;
nextCaretPosition = maskResult.caretPosition;
} else {
maskedValue = maskDateInputValue( dateFormat, value );
}
setInputValue( maskedValue );
const previousSelectedValue = selectedValueRef.current;
let nextSelectedValue = previousSelectedValue;
let shouldNotifyChange = false;
let nextChangePayload = previousSelectedValue;
const parsedDate = usesTokenFormat ? parseDateFromMaskedValue( dateFormat, maskedValue ) : null;
if ( parsedDate ) {
const previousTime = previousSelectedValue instanceof Date ? previousSelectedValue.getTime() : null;
const parsedTime = parsedDate.getTime();
if ( previousTime !== parsedTime ) {
shouldNotifyChange = true;
nextChangePayload = parsedDate;
}
nextSelectedValue = parsedDate;
} else if ( maskedValue === '' ) {
if ( previousSelectedValue ) {
shouldNotifyChange = true;
nextChangePayload = null;
}
nextSelectedValue = null;
}
if ( usesTokenFormat ) {
setInputHasError( maskedValue !== '' && ! parsedDate );
} else {
setInputHasError( false );
}
selectedValueRef.current = nextSelectedValue;
if ( shouldNotifyChange ) {
emitChange( nextChangePayload, event );
if ( externalCalendarOnChange ) {
externalCalendarOnChange( nextChangePayload, event );
}
}
if ( externalInputOnChange ) {
externalInputOnChange( maskedValue, event );
}
if ( nextCaretPosition !== null ) {
const scheduler =
typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function'
? window.requestAnimationFrame
: ( callback ) => setTimeout( callback, 0 );
scheduler( () => {
if ( inputElementRef.current && typeof inputElementRef.current.setSelectionRange === 'function' ) {
inputElementRef.current.setSelectionRange( nextCaretPosition, nextCaretPosition );
}
} );
}
};
const handleCalendarChange = ( value, event ) => {
selectedValueRef.current = value;
setInputValue( formatSelectedValue( value ) );
setInputHasError( false );
emitChange( value, event );
if ( externalCalendarOnChange ) {
externalCalendarOnChange( value, event );
}
setIsCalendarOpen( false );
};
const shouldIgnoreTarget = ( target ) => {
return (
inputRef.current?.contains( target ) ||
calendarRef.current?.contains( target )
);
};
// Handle outside interactions to close calendar without focus management
useEffect( () => {
const handleDocumentClick = ( event ) => {
// Only handle if calendar is open
if ( ! isCalendarOpen ) {
return;
}
// Don't close if clicking on input or calendar
if ( shouldIgnoreTarget( event.target ) ) {
return;
}
// Close calendar without any focus management
setIsCalendarOpen( false );
};
const handleDocumentKeyDown = ( event ) => {
// Close calendar on escape key without focus management
if ( event.key === 'Escape' && isCalendarOpen ) {
event.preventDefault();
setIsCalendarOpen( false );
}
};
const handleDocumentFocusIn = ( event ) => {
if ( ! isCalendarOpen ) {
return;
}
if ( shouldIgnoreTarget( event.target ) ) {
return;
}
setIsCalendarOpen( false );
};
document.addEventListener( 'mousedown', handleDocumentClick );
document.addEventListener( 'keydown', handleDocumentKeyDown );
document.addEventListener( 'focusin', handleDocumentFocusIn );
return () => {
document.removeEventListener( 'mousedown', handleDocumentClick );
document.removeEventListener( 'keydown', handleDocumentKeyDown );
document.removeEventListener( 'focusin', handleDocumentFocusIn );
};
}, [ isCalendarOpen ] );
const componentProps = {
className: classnames( {
'gform-date-picker': true,
...spacerClasses( spacing ),
}, customClasses ),
ref,
...customAttributes,
};
let placeholderValue;
if ( typeof externalInputPlaceholder !== 'undefined' ) {
placeholderValue = externalInputPlaceholder;
} else if ( typeof dateFormat === 'string' ) {
placeholderValue = dateFormat;
} else {
placeholderValue = DEFAULT_DATE_FORMAT;
}
const handleInputElementRef = ( node ) => {
inputElementRef.current = node;
const externalRef = externalInputCustomAttributes?.ref;
if ( typeof externalRef === 'function' ) {
externalRef( node );
} else if ( externalRef && typeof externalRef === 'object' ) {
externalRef.current = node;
}
};
const inputCustomAttributes = {
...externalInputCustomAttributes,
ref: handleInputElementRef,
};
const inputProps = {
customClasses: classnames( [
'gform-date-picker__input',
] ),
iconAttributes: {
customClasses: classnames( [
'gform-date-picker__icon',
] ),
icon: 'calendar',
iconPrefix: 'gravity-component-icon',
},
...restInputAttributes,
directControlled: true,
onChange: handleInputChange,
onFocus: handleInputFocus,
onBlur: handleInputBlur,
customAttributes: inputCustomAttributes,
placeholder: placeholderValue,
ref: inputRef,
width: 'full',
borderStyle: inputHasError ? 'error' : externalInputBorderStyle,
value: inputValue,
};
const resolvedCalendarValue = typeof externalCalendarValue !== 'undefined'
? externalCalendarValue
: selectedValueRef.current;
const fallbackActiveStartDate = getPrimaryDateFromValue( resolvedCalendarValue );
const resolvedActiveStartDate = typeof externalCalendarActiveStartDate !== 'undefined'
? externalCalendarActiveStartDate
: fallbackActiveStartDate;
const calendarAttributesProps = {
...restExternalCalendarAttributes,
...( typeof resolvedCalendarValue !== 'undefined' && resolvedCalendarValue !== null
? { value: resolvedCalendarValue }
: {} ),
...( resolvedActiveStartDate ? { activeStartDate: resolvedActiveStartDate } : {} ),
onChange: handleCalendarChange,
};
const calendarProps = {
...restCalendarAttributes,
calendarAttributes: calendarAttributesProps,
customClasses: classnames( [
'gform-date-picker__calendar',
], externalCalendarClasses ),
externalControl: true,
isOpen: isCalendarOpen,
trapFocus: false,
// Disable calendar's built-in close behavior - we handle everything via focus/blur
onClose: externalCalendarOnClose ?? ( () => {} ),
onOpen: externalCalendarOnOpen ?? ( () => {} ),
ref: calendarRef,
withTrigger: false,
};
return (
<div { ...componentProps }>
<Input { ...inputProps } />
<Calendar { ...calendarProps } />
</div>
);
} );
DatePicker.propTypes = {
calendarAttributes: PropTypes.object,
customAttributes: PropTypes.object,
customClasses: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.array,
PropTypes.object,
] ),
dateFormat: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.func,
] ),
inputAttributes: PropTypes.object,
onChange: PropTypes.func,
spacing: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.number,
PropTypes.array,
PropTypes.object,
] ),
};
DatePicker.displayName = 'DatePicker';
export default DatePicker;