/**
* External dependencies
*/
import clsx from 'clsx';
import {
format,
isValid as isValidDate,
subMonths,
subDays,
subYears,
startOfMonth,
startOfYear,
} from 'date-fns';
/**
* WordPress dependencies
*/
import {
BaseControl,
Button,
Icon,
privateApis as componentsPrivateApis,
__experimentalInputControl as InputControl,
} from '@wordpress/components';
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { getDate, getSettings } from '@wordpress/date';
import { error as errorIcon } from '@wordpress/icons';
import { Stack } from '@wordpress/ui';
/**
* Internal dependencies
*/
import RelativeDateControl from './utils/relative-date-control';
import {
OPERATOR_IN_THE_PAST,
OPERATOR_OVER,
OPERATOR_BETWEEN,
} from '../../constants';
import { unlock } from '../../lock-unlock';
import type {
DataFormControlProps,
FieldValidity,
FormatDate,
NormalizedField,
} from '../../types';
import getCustomValidity from './utils/get-custom-validity';
const { DateCalendar, DateRangeCalendar } = unlock( componentsPrivateApis );
type DateRange = [ string, string ] | undefined;
const DATE_PRESETS: {
id: string;
label: string;
getValue: () => Date;
}[] = [
{
id: 'today',
label: __( 'Today' ),
getValue: () => getDate( null ),
},
{
id: 'yesterday',
label: __( 'Yesterday' ),
getValue: () => {
const today = getDate( null );
return subDays( today, 1 );
},
},
{
id: 'past-week',
label: __( 'Past week' ),
getValue: () => {
const today = getDate( null );
return subDays( today, 7 );
},
},
{
id: 'past-month',
label: __( 'Past month' ),
getValue: () => {
const today = getDate( null );
return subMonths( today, 1 );
},
},
];
const DATE_RANGE_PRESETS = [
{
id: 'last-7-days',
label: __( 'Last 7 days' ),
getValue: () => {
const today = getDate( null );
return [ subDays( today, 7 ), today ];
},
},
{
id: 'last-30-days',
label: __( 'Last 30 days' ),
getValue: () => {
const today = getDate( null );
return [ subDays( today, 30 ), today ];
},
},
{
id: 'month-to-date',
label: __( 'Month to date' ),
getValue: () => {
const today = getDate( null );
return [ startOfMonth( today ), today ];
},
},
{
id: 'last-year',
label: __( 'Last year' ),
getValue: () => {
const today = getDate( null );
return [ subYears( today, 1 ), today ];
},
},
{
id: 'year-to-date',
label: __( 'Year to date' ),
getValue: () => {
const today = getDate( null );
return [ startOfYear( today ), today ];
},
},
];
const parseDate = ( dateString?: string ): Date | null => {
if ( ! dateString ) {
return null;
}
const parsed = getDate( dateString );
return parsed && isValidDate( parsed ) ? parsed : null;
};
const formatDate = ( date?: Date | string ): string => {
if ( ! date ) {
return '';
}
return typeof date === 'string' ? date : format( date, 'yyyy-MM-dd' );
};
function ValidatedDateControl< Item >( {
field,
validity,
inputRefs,
isTouched,
setIsTouched,
children,
}: {
field: NormalizedField< Item >;
validity?: FieldValidity;
inputRefs:
| React.RefObject< HTMLInputElement | null >
| React.RefObject< HTMLInputElement | null >[];
isTouched: boolean;
setIsTouched: ( touched: boolean ) => void;
children: React.ReactNode;
} ) {
const { isValid } = field;
const [ customValidity, setCustomValidity ] = useState<
| { type: 'valid' | 'validating' | 'invalid'; message?: string }
| undefined
>( undefined );
const validateRefs = useCallback( () => {
// Check HTML5 validity on all refs
const refs = Array.isArray( inputRefs ) ? inputRefs : [ inputRefs ];
for ( const ref of refs ) {
const input = ref.current;
if ( input && ! input.validity.valid ) {
setCustomValidity( {
type: 'invalid',
message: input.validationMessage,
} );
return;
}
}
// No errors
setCustomValidity( undefined );
}, [ inputRefs ] );
// Sync React-level validation to native inputs.
useEffect( () => {
const refs = Array.isArray( inputRefs ) ? inputRefs : [ inputRefs ];
const result = validity
? getCustomValidity( isValid, validity )
: undefined;
for ( const ref of refs ) {
const input = ref.current;
if ( input ) {
input.setCustomValidity(
result?.type === 'invalid' && result.message
? result.message
: ''
);
}
}
}, [ inputRefs, isValid, validity ] );
// Listen for 'invalid' events (e.g., from reportValidity() on card re-expand).
useEffect( () => {
const refs = Array.isArray( inputRefs ) ? inputRefs : [ inputRefs ];
const handleInvalid = ( event: Event ) => {
event.preventDefault();
setIsTouched( true );
};
for ( const ref of refs ) {
ref.current?.addEventListener( 'invalid', handleInvalid );
}
return () => {
for ( const ref of refs ) {
ref.current?.removeEventListener( 'invalid', handleInvalid );
}
};
}, [ inputRefs, setIsTouched ] );
useEffect( () => {
if ( ! isTouched ) {
return;
}
const result = validity
? getCustomValidity( isValid, validity )
: undefined;
if ( result ) {
setCustomValidity( result );
} else {
validateRefs();
}
}, [ isTouched, isValid, validity, validateRefs ] );
const onBlur = ( event: React.FocusEvent< HTMLDivElement > ) => {
if ( isTouched ) {
return;
}
// Only consider "blurred from the component" if focus has fully left the wrapping div.
// This prevents unnecessary blurs from components with multiple focusable elements.
if (
! event.relatedTarget ||
! event.currentTarget.contains( event.relatedTarget )
) {
setIsTouched( true );
}
};
return (
{ children }
{ customValidity && (
{ customValidity.message }
) }
);
}
function CalendarDateControl< Item >( {
data,
field,
onChange,
hideLabelFromVision,
markWhenOptional,
validity,
}: DataFormControlProps< Item > ) {
const {
id,
label,
description,
setValue,
getValue,
isValid,
format: fieldFormat,
} = field;
const [ selectedPresetId, setSelectedPresetId ] = useState< string | null >(
null
);
const weekStartsOn =
( fieldFormat as FormatDate ).weekStartsOn ??
getSettings().l10n.startOfWeek;
const fieldValue = getValue( { item: data } );
const value = typeof fieldValue === 'string' ? fieldValue : undefined;
const [ calendarMonth, setCalendarMonth ] = useState< Date >( () => {
const parsedDate = parseDate( value );
return parsedDate || new Date(); // Default to current month
} );
const [ isTouched, setIsTouched ] = useState( false );
const validityTargetRef = useRef< HTMLInputElement >( null );
const onChangeCallback = useCallback(
( newValue: string | undefined ) =>
onChange( setValue( { item: data, value: newValue } ) ),
[ data, onChange, setValue ]
);
const onSelectDate = useCallback(
( newDate: Date | undefined | null ) => {
const dateValue = newDate
? format( newDate, 'yyyy-MM-dd' )
: undefined;
onChangeCallback( dateValue );
setSelectedPresetId( null );
setIsTouched( true );
},
[ onChangeCallback ]
);
const handlePresetClick = useCallback(
( preset: ( typeof DATE_PRESETS )[ 0 ] ) => {
const presetDate = preset.getValue();
const dateValue = formatDate( presetDate );
setCalendarMonth( presetDate );
onChangeCallback( dateValue );
setSelectedPresetId( preset.id );
setIsTouched( true );
},
[ onChangeCallback ]
);
const handleManualDateChange = useCallback(
( newValue?: string ) => {
onChangeCallback( newValue );
if ( newValue ) {
const parsedDate = parseDate( newValue );
if ( parsedDate ) {
setCalendarMonth( parsedDate );
}
}
setSelectedPresetId( null );
setIsTouched( true );
},
[ onChangeCallback ]
);
const {
timezone: { string: timezoneString },
} = getSettings();
let displayLabel = label;
if ( isValid?.required && ! markWhenOptional ) {
displayLabel = `${ label } (${ __( 'Required' ) })`;
} else if ( ! isValid?.required && markWhenOptional ) {
displayLabel = `${ label } (${ __( 'Optional' ) })`;
}
return (
{ /* Preset buttons */ }
{ DATE_PRESETS.map( ( preset ) => {
const isSelected = selectedPresetId === preset.id;
return (
);
} ) }
{ /* Manual date input */ }
{ /* Calendar widget */ }
);
}
function CalendarDateRangeControl< Item >( {
data,
field,
onChange,
hideLabelFromVision,
markWhenOptional,
validity,
}: DataFormControlProps< Item > ) {
const {
id,
label,
description,
getValue,
setValue,
format: fieldFormat,
} = field;
let value: DateRange;
const fieldValue = getValue( { item: data } );
if (
Array.isArray( fieldValue ) &&
fieldValue.length === 2 &&
fieldValue.every( ( date ) => typeof date === 'string' )
) {
value = fieldValue as DateRange;
}
const weekStartsOn =
( fieldFormat as FormatDate ).weekStartsOn ??
getSettings().l10n.startOfWeek;
const onChangeCallback = useCallback(
( newValue: DateRange ) => {
onChange(
setValue( {
item: data,
value: newValue,
} )
);
},
[ data, onChange, setValue ]
);
const [ selectedPresetId, setSelectedPresetId ] = useState< string | null >(
null
);
const selectedRange = useMemo( () => {
if ( ! value ) {
return { from: undefined, to: undefined };
}
const [ from, to ] = value;
return {
from: parseDate( from ) || undefined,
to: parseDate( to ) || undefined,
};
}, [ value ] );
const [ calendarMonth, setCalendarMonth ] = useState< Date >( () => {
return selectedRange.from || new Date();
} );
const [ isTouched, setIsTouched ] = useState( false );
const fromInputRef = useRef< HTMLInputElement >( null );
const toInputRef = useRef< HTMLInputElement >( null );
const updateDateRange = useCallback(
( fromDate?: Date | string, toDate?: Date | string ) => {
if ( fromDate && toDate ) {
onChangeCallback( [
formatDate( fromDate ),
formatDate( toDate ),
] );
} else if ( ! fromDate && ! toDate ) {
onChangeCallback( undefined );
}
// Do nothing if only one date is set - wait for both
},
[ onChangeCallback ]
);
const onSelectCalendarRange = useCallback(
(
newRange:
| { from: Date | undefined; to?: Date | undefined }
| undefined
) => {
updateDateRange( newRange?.from, newRange?.to );
setSelectedPresetId( null );
setIsTouched( true );
},
[ updateDateRange ]
);
const handlePresetClick = useCallback(
( preset: ( typeof DATE_RANGE_PRESETS )[ 0 ] ) => {
const [ startDate, endDate ] = preset.getValue();
setCalendarMonth( startDate );
updateDateRange( startDate, endDate );
setSelectedPresetId( preset.id );
setIsTouched( true );
},
[ updateDateRange ]
);
const handleManualDateChange = useCallback(
( fromOrTo: 'from' | 'to', newValue?: string ) => {
const [ currentFrom, currentTo ] = value || [
undefined,
undefined,
];
const updatedFrom = fromOrTo === 'from' ? newValue : currentFrom;
const updatedTo = fromOrTo === 'to' ? newValue : currentTo;
updateDateRange( updatedFrom, updatedTo );
if ( newValue ) {
const parsedDate = parseDate( newValue );
if ( parsedDate ) {
setCalendarMonth( parsedDate );
}
}
setSelectedPresetId( null );
setIsTouched( true );
},
[ value, updateDateRange ]
);
const { timezone } = getSettings();
let displayLabel = label;
if ( field.isValid?.required && ! markWhenOptional ) {
displayLabel = `${ label } (${ __( 'Required' ) })`;
} else if ( ! field.isValid?.required && markWhenOptional ) {
displayLabel = `${ label } (${ __( 'Optional' ) })`;
}
return (
{ /* Preset buttons */ }
{ DATE_RANGE_PRESETS.map( ( preset ) => {
const isSelected = selectedPresetId === preset.id;
return (
);
} ) }
{ /* Manual date range inputs */ }
handleManualDateChange( 'from', newValue )
}
required={ !! field.isValid?.required }
/>
handleManualDateChange( 'to', newValue )
}
required={ !! field.isValid?.required }
/>
);
}
export default function DateControl< Item >( {
data,
field,
onChange,
hideLabelFromVision,
markWhenOptional,
operator,
validity,
}: DataFormControlProps< Item > ) {
if ( operator === OPERATOR_IN_THE_PAST || operator === OPERATOR_OVER ) {
return (
);
}
if ( operator === OPERATOR_BETWEEN ) {
return (
);
}
return (
);
}