modules_DatePicker_index.js

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;