modules_Calendar_index.js

import { React, classnames, ReactCalendar, PropTypes } from '@gravityforms/libraries';
import { useStateWithDep } from '@gravityforms/react-utils';
import { spacerClasses, deepMerge } from '@gravityforms/utils';
import { LeftArrow, RightArrow, DoubleLeftArrow, DoubleRightArrow } from './Icons';
import Button from '../../elements/Button';

const { forwardRef, useRef, useEffect, Fragment } = React;

/**
 * @constant
 * @type {object}
 * @description Default attributes for the ReactCalendar component.
 *
 * @since 4.4.0
 *
 * @property {string|JSX.Element} nextLabel  The next button label.
 * @property {string|JSX.Element} next2Label The next2 button label.
 * @property {Function}           onChange   The onChange event handler.
 * @property {string|JSX.Element} prevLabel  The previous button label.
 * @property {string|JSX.Element} prev2Label The previous2 button label.
 */
const defaultCalendarAttributes = {
	maxDetail: 'month',
	nextLabel: <RightArrow />,
	next2Label: <DoubleRightArrow />,
	onActiveStartDateChange: () => {},
	onChange: () => {},
	onViewChange: () => {},
	prevLabel: <LeftArrow />,
	prev2Label: <DoubleLeftArrow />,
};

/**
 * @function getTodayStartOfDay
 * @description Get the date object of the start of day for today.
 *
 * @since 4.4.0
 *
 * @return {Date} The date object of the start of day for today.
 */
const getTodayStartOfDay = () => {
	const date = new Date();
	date.setHours( 0, 0, 0, 0 );
	return date;
};

/**
 * @function getTodayEndOfDay
 * @description Get the date object of the end of day for today.
 *
 * @since 4.4.0
 *
 * @return {Date} The date object of the end of day for today.
 */
const getTodayEndOfDay = () => {
	const date = new Date();
	date.setHours( 23, 59, 59, 999 );
	return date;
};

/**
 * @function getStart
 * @description Get the start date based on range type.
 *
 * @since 4.4.0
 *
 * @param {string} rangeType The range type, one of `century`, `decade`, `year`, or `month`.
 * @param {Date}   date      The date object.
 *
 * @return {Date} The start of range type date object.
 */
const getStart = ( rangeType, date ) => {
	let year = date.getFullYear();
	let month = 0;

	switch ( rangeType ) {
		case 'century':
			year = year + ( ( -year + 1 ) % 100 );
			break;
		case 'decade':
			year = year + ( ( -year + 1 ) % 10 );
			break;
		case 'year':
			break;
		case 'month':
			month = date.getMonth();
			break;
		default:
			throw new Error( `Invalid rangeType: ${ rangeType }` );
	}

	const begin = new Date();
	begin.setFullYear( year, month, 1 );
	begin.setHours( 0, 0, 0, 0 );
	return begin;
};

/**
 * @module Calendar
 * @description A calendar component. The calendar is run in controlled mode to allow for the today button to work.
 *
 * @since 4.4.0
 *
 * @param {object}                     props                    Component props.
 * @param {object}                     props.calendarAttributes Custom attributes for the calendar.
 * @param {string|Array|object}        props.calendarClasses    Custom classes for the calendar.
 * @param {object}                     props.customAttributes   Custom attributes for the component.
 * @param {string|Array|object}        props.customClasses      Custom classes for the component.
 * @param {Function}                   props.onTodayClick       The click event handler for the today button.
 * @param {boolean}                    props.showTodayButton    Whether to show the today button or not.
 * @param {string|number|Array|object} props.spacing            The spacing for the component, as a string, number, array, or object.
 * @param {boolean}                    props.triggerActive      Whether the trigger is active or not.
 * @param {object}                     props.triggerAttributes  Custom attributes for the trigger button.
 * @param {string|Array|object}        props.triggerClasses     Custom classes for the trigger button.
 * @param {boolean}                    props.withTrigger        Whether to show the trigger button or not.
 *
 * @return {object} The Calendar component.
 *
 * @example
 * import Calendar from '@gravityforms/components/react/admin/modules/Calendar';
 *
 * return <Calendar />;
 *
 */
const Calendar = forwardRef( ( {
	calendarAttributes = {},
	calendarClasses = [],
	customAttributes = {},
	customClasses = [],
	onTodayClick = () => {},
	showTodayButton = true,
	spacing = '',
	triggerActive = false,
	triggerAttributes = {},
	triggerClasses = [],
	withTrigger = false,
}, ref ) => {
	const [ activeStartDate, setActiveStartDate ] = useStateWithDep( calendarAttributes.activeStartDate || null );
	const [ value, setValue ] = useStateWithDep( calendarAttributes.value || null );
	const [ view, setView ] = useStateWithDep( calendarAttributes.view || null );
	const [ triggerIsActive, setTriggerIsActive ] = useStateWithDep( triggerActive );

	// Refs
	const calendarRef = useRef( null );
	const triggerRef = useRef( null );

	const mergedCalendarAttributes = deepMerge( defaultCalendarAttributes, calendarAttributes );
	const maxDetail = mergedCalendarAttributes.maxDetail || 'month';

	const calendarWrapperProps = {
		className: classnames( {
			'gform-calendar': true,
			'gform-calendar--with-trigger': withTrigger,
			'gform-calendar--trigger-active': triggerIsActive,
			...( withTrigger ? {} : spacerClasses( spacing ) ),
		}, customClasses ),
		...customAttributes,
	};

	const adjustCalendarPosition = () => {
		if ( triggerRef.current && calendarRef.current ) {
			calendarRef.current.style.marginInlineStart = 0;

			const calendarRect = calendarRef.current.getBoundingClientRect();
			const viewportWidth = window.innerWidth;

			let marginInlineStart = 0;

			if ( ( calendarRect.right + 20 ) - viewportWidth >= 0 ) {
				marginInlineStart = -( ( calendarRect.right + 20 ) - viewportWidth );
			}

			calendarRef.current.style.marginInlineStart = `${ marginInlineStart }px`;
		}
	};

	useEffect( () => {
		if ( withTrigger && triggerIsActive ) {
			adjustCalendarPosition();

			// eslint-disable-next-line jsdoc/require-description
			/**
			 *
			 * @param event
			 */
			// eslint-disable-next-line no-inner-declarations
			function handleClickOutside( event ) {
				if (
					calendarRef.current &&
					! calendarRef.current.contains( event.target ) &&
					triggerRef.current &&
					! triggerRef.current.contains( event.target )
				) {
					setTriggerIsActive( false );
				}
			}

			document.addEventListener( 'mousedown', handleClickOutside );
			window.addEventListener( 'resize', adjustCalendarPosition );

			return () => {
				document.removeEventListener( 'mousedown', handleClickOutside );
				window.removeEventListener( 'resize', adjustCalendarPosition );
			};
		}
	}, [ withTrigger, triggerIsActive ] );

	const resetControl = ( args ) => {
		if ( activeStartDate ) {
			setActiveStartDate( null );
		}
		setView( args.view );
	};

	const calendarProps = deepMerge( defaultCalendarAttributes, {
		className: classnames( [
			'gform-calendar__calendar',
		], calendarClasses ),
		...mergedCalendarAttributes,
		activeStartDate,
		onActiveStartDateChange: ( args ) => {
			if ( [ 'drillDown', 'drillUp' ].includes( args.action ) ) {
				return;
			}
			resetControl( args );
			mergedCalendarAttributes.onActiveStartDateChange( args );
		},
		onChange: ( newValue, event ) => {
			setValue( newValue );
			mergedCalendarAttributes.onChange( newValue, event );
		},
		onViewChange: ( args ) => {
			resetControl( args );
			mergedCalendarAttributes.onViewChange( args );
		},
		value,
		view,
	} );

	const todayButtonProps = {
		label: 'Today',
		onClick: () => {
			const today = new Date();
			const newValue = calendarProps.selectRange
				? [ getTodayStartOfDay(), getTodayEndOfDay() ]
				: today;
			setActiveStartDate( today );
			setView( maxDetail );
			onTodayClick();
			calendarProps.onChange( newValue );
			if ( view !== maxDetail ) {
				calendarProps.onViewChange( {
					action: 'drillDown',
					activeStartDate: getStart( maxDetail, today ),
					value: today,
					view: maxDetail,
				} );
			}
		},
		size: 'size-height-s',
		type: 'white',
	};

	const triggerProps = {
		className: classnames( 'gform-calendar__trigger', triggerClasses ),
		onClick: () => {
			setTriggerIsActive( ! triggerIsActive );
		},
		size: 'size-height-m',
		type: 'white',
		...triggerAttributes,
	};

	const outerWrapperProps = {
		className: classnames( {
			'gform-calendar__wrapper': true,
			'gform-calendar__wrapper--trigger-active': triggerIsActive,
			...( withTrigger ? spacerClasses( spacing ) : {} ),
		} ),
	};

	const OuterWrapper = withTrigger ? 'div' : Fragment;

	return (
		<OuterWrapper { ...( OuterWrapper === 'div' ? outerWrapperProps : {} ) }>
			{ withTrigger && <Button { ...triggerProps } ref={ triggerRef } /> }
			<div { ...calendarWrapperProps } ref={ ( node ) => {
				calendarRef.current = node;
				if ( typeof ref === 'function' ) {
					ref( node );
				} else if ( ref ) {
					ref.current = node;
				}
			} }>
				<ReactCalendar.Calendar { ...calendarProps } />
				{ showTodayButton && (
					<div className="gform-calendar__footer">
						<Button { ...todayButtonProps } />
					</div>
				) }
			</div>
		</OuterWrapper>
	);
} );

Calendar.propTypes = {
	calendarAttributes: PropTypes.object,
	calendarClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	customAttributes: PropTypes.object,
	customClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	onTodayClick: PropTypes.func,
	showTodayButton: PropTypes.bool,
	spacing: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.number,
		PropTypes.array,
		PropTypes.object,
	] ),
	triggerActive: PropTypes.bool,
	triggerAttributes: PropTypes.object,
	triggerClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	withTrigger: PropTypes.bool,
};

export default Calendar;