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;