UNPKG

@gravityforms/components

Version:

UI components for use in Gravity Forms development. Both React and vanilla js flavors.

457 lines (409 loc) 15 kB
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;