/** * 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 ( ); }