modules_Calendar_index.js

import { React, classnames, ReactCalendar, PropTypes } from '@gravityforms/libraries';
import {
	IdProvider,
	useFocusTrap,
	useIdContext,
	usePopup,
	useStateWithDep,
} from '@gravityforms/react-utils';
import { spacerClasses, deepMerge, getClosest } 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 CalendarComponent
 * @description A calendar component that uses the ReactCalendar component.
 *
 * @since 4.4.0
 *
 * @param {object} props Component props.
 * @param {object} ref   Ref to the component.
 *
 * @return {JSX.Element} The calendar component.
 */
const CalendarComponent = forwardRef( ( props, ref ) => {
	const {
		calendarAttributes,
		calendarClasses,
		closeOnChange,
		customAttributes,
		customClasses,
		onAfterClose,
		onAfterOpen,
		onClose,
		onOpen,
		onResetClick,
		onTodayClick,
		resetAttributes,
		resetClasses,
		showResetButton,
		showTodayButton,
		spacing,
		todayAttributes,
		todayClasses,
		triggerAttributes,
		triggerClasses,
		withTrigger,
	} = props;
	const [ activeStartDate, setActiveStartDate ] = useStateWithDep( calendarAttributes.activeStartDate || null );
	const [ value, setValue ] = useStateWithDep( calendarAttributes.value || null );
	const [ view, setView ] = useStateWithDep( calendarAttributes.view || null );
	const id = useIdContext();

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

	const {
		closePopup,
		openPopup,
		handleEscKeyDown,
		popupHide,
		popupOpen,
		popupReveal,
	} = usePopup( {
		customClickOutsideLogic: ( event ) => {
			const clickIsTile = event.target.classList.contains( 'react-calendar__tile' );
			const clickInCalendar = [
				'.react-calendar__tile',
				'.react-calendar__month-view',
				'.react-calendar__year-view',
				'.react-calendar__decade-view',
				'.react-calendar__century-view',
			].reduce( ( carry, selector ) => {
				if ( carry ) {
					return carry;
				}
				const closest = getClosest( event.target, selector );
				if ( closest ) {
					return true;
				}
				return false;
			}, false );
			return clickIsTile || clickInCalendar;
		},
		onAfterClose,
		onAfterOpen,
		onClose,
		onOpen,
		popupRef: calendarRef,
		triggerRef,
	} );

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

	const {
		onKeyDown: calendarWrapperOnKeyDown = () => {},
		...restCalendarWrapperAttributes
	} = customAttributes;
	const calendarWrapperProps = {
		className: classnames( {
			'gform-calendar': true,
			'gform-calendar--with-trigger': withTrigger,
			...( withTrigger ? {} : spacerClasses( spacing ) ),
		}, customClasses ),
		...restCalendarWrapperAttributes,
	};
	if ( withTrigger ) {
		calendarWrapperProps.onKeyDown = ( event ) => {
			handleEscKeyDown( event );
			calendarWrapperOnKeyDown( event );
		};
	}

	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 && popupOpen ) {
			adjustCalendarPosition();
			window.addEventListener( 'resize', adjustCalendarPosition );

			return () => {
				window.removeEventListener( 'resize', adjustCalendarPosition );
			};
		}
	}, [ withTrigger, popupOpen ] );

	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 );
			if ( closeOnChange ) {
				closePopup();
			}
		},
		onViewChange: ( args ) => {
			resetControl( args );
			mergedCalendarAttributes.onViewChange( args );
		},
		value,
		view,
	} );

	const todayButtonProps = {
		label: 'Today',
		customClasses: classnames( 'gform-calendar__today-button', todayClasses ),
		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,
				} );
			}
			if ( closeOnChange ) {
				closePopup();
			}
		},
		size: 'size-height-s',
		type: 'white',
		...todayAttributes,
	};

	const resetButtonProps = {
		label: 'Reset',
		customClasses: classnames( 'gform-calendar__reset-button', resetClasses ),
		onClick: () => {
			const newValue = calendarProps.selectRange ? [] : null;
			onResetClick();
			calendarProps.onChange( newValue );
			if ( closeOnChange ) {
				closePopup();
			}
		},
		size: 'size-height-s',
		type: 'white',
		...resetAttributes,
	};

	const {
		ariaId: triggerAriaId = `${ id }-trigger-aria`,
		ariaText: triggerAriaText = '',
		customAttributes: triggerCustomAttributes = {},
		id: triggerId = `${ id }-trigger`,
		onClick: triggerOnClick = () => {},
		onKeyDown: triggerOnKeyDown = () => {},
		title: triggerTitle = '',
		...restTriggerAttributes
	} = triggerAttributes;
	const triggerProps = {
		className: classnames( 'gform-calendar__trigger', triggerClasses ),
		customAttributes: {
			'aria-expanded': popupOpen ? 'true' : 'false',
			'aria-haspopup': 'dialog',
			'aria-labelledby': triggerTitle ? undefined : `${ triggerAriaId } ${ triggerId }`,
			id: triggerId,
			onKeyDown: ( event ) => {
				handleEscKeyDown( event );
				triggerOnKeyDown( event );
			},
			title: triggerTitle || undefined,
			...triggerCustomAttributes,
		},
		onClick: ( event ) => {
			triggerOnClick( event );
			if ( popupOpen ) {
				closePopup();
			} else {
				openPopup();
			}
		},
		size: 'size-height-m',
		type: 'white',
		...restTriggerAttributes,
	};

	const outerWrapperProps = {
		className: classnames( {
			'gform-calendar__wrapper': true,
			'gform-calendar__wrapper--open': popupOpen,
			'gform-calendar__wrapper--hide': popupHide,
			'gform-calendar__wrapper--reveal': popupReveal,
			...( withTrigger ? spacerClasses( spacing ) : {} ),
		} ),
		ref: trapRef,
	};

	const OuterWrapper = withTrigger ? 'div' : Fragment;
	const showFooter = showTodayButton || showResetButton;

	return (
		<OuterWrapper { ...( OuterWrapper === 'div' ? outerWrapperProps : {} ) }>
			{ withTrigger && (
				<>
					<span
						className="gform-visually-hidden"
						id={ triggerAriaId }
					>
						{ triggerAriaText }
					</span>
					<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 } />
				{ showFooter && (
					<div className="gform-calendar__footer">
						{ showTodayButton && <Button { ...todayButtonProps } /> }
						{ showResetButton && <Button { ...resetButtonProps } /> }
					</div>
				) }
			</div>
		</OuterWrapper>
	);
} );

/**
 * @module Calendar
 * @description A calendar component with id wrapper. 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 {boolean}                    props.closeOnChange      Close the calendar when the value changes.
 * @param {object}                     props.customAttributes   Custom attributes for the component.
 * @param {string|Array|object}        props.customClasses      Custom classes for the component.
 * @param {string}                     props.id                 The id for the component.
 * @param {Function}                   props.onAfterClose       The after close event handler.
 * @param {Function}                   props.onAfterOpen        The after open event handler.
 * @param {Function}                   props.onClose            The close event handler.
 * @param {Function}                   props.onOpen             The open event handler.
 * @param {Function}                   props.onResetClick       The click event handler for the reset button.
 * @param {Function}                   props.onTodayClick       The click event handler for the today button.
 * @param {object}                     props.resetAttributes    Custom attributes for the reset button.
 * @param {string|Array|object}        props.resetClasses       Custom classes for the reset button.
 * @param {boolean}                    props.showResetButton    Whether to show the reset button or not.
 * @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 {object}                     props.todayAttributes    Custom attributes for the today button.
 * @param {string|Array|object}        props.todayClasses       Custom classes for the today button.
 * @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 {JSX.Element} The Calendar component.
 *
 * @example
 * import Calendar from '@gravityforms/components/react/admin/modules/Calendar';
 *
 * return <Calendar />;
 *
 */
const Calendar = forwardRef( ( props, ref ) => {
	const defaultProps = {
		calendarAttributes: {},
		calendarClasses: [],
		closeOnChange: true,
		customAttributes: {},
		customClasses: [],
		id: '',
		onAfterClose: () => {},
		onAfterOpen: () => {},
		onClose: () => {},
		onOpen: () => {},
		onResetClick: () => {},
		onTodayClick: () => {},
		resetAttributes: {},
		resetClasses: [],
		showResetButton: false,
		showTodayButton: true,
		spacing: '',
		todayAttributes: {},
		todayClasses: [],
		triggerAttributes: {},
		triggerClasses: [],
		withTrigger: false,
	};
	const combinedProps = { ...defaultProps, ...props };
	const { id: idProp } = combinedProps;
	const idProviderProps = { id: idProp };

	return (
		<IdProvider { ...idProviderProps }>
			<CalendarComponent { ...combinedProps } ref={ ref } />
		</IdProvider>
	);
} );

Calendar.propTypes = {
	calendarAttributes: PropTypes.object,
	calendarClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	closeOnChange: PropTypes.bool,
	customAttributes: PropTypes.object,
	customClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	id: PropTypes.string,
	onAfterClose: PropTypes.func,
	onAfterOpen: PropTypes.func,
	onClose: PropTypes.func,
	onOpen: PropTypes.func,
	onResetClick: PropTypes.func,
	onTodayClick: PropTypes.func,
	resetAttributes: PropTypes.object,
	resetClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	showResetButton: PropTypes.bool,
	showTodayButton: PropTypes.bool,
	spacing: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.number,
		PropTypes.array,
		PropTypes.object,
	] ),
	todayAttributes: PropTypes.object,
	todayClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	triggerAttributes: PropTypes.object,
	triggerClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	withTrigger: PropTypes.bool,
};

export default Calendar;