@@ -45,7 +45,12 @@ export type SelectInnerClassNames = {
4545} ;
4646
4747export type FieldPathByValue < TFieldValues extends FieldValues , TValue > = {
48- [ Key in FieldPath < TFieldValues > ] : FieldPathValue < TFieldValues , Key > extends TValue ? Key : never ;
48+ [ Key in FieldPath < TFieldValues > ] : FieldPathValue <
49+ TFieldValues ,
50+ Key
51+ > extends TValue
52+ ? Key
53+ : never ;
4954} [ FieldPath < TFieldValues > ] ;
5055
5156export const ScheduleDay = < TFieldValues extends FieldValues > ( {
@@ -404,12 +409,35 @@ const TimeRangeField = ({
404409 ) ;
405410} ;
406411
412+ export function parseTimeString (
413+ input : string ,
414+ timeFormat : number | null
415+ ) : Date | null {
416+ if ( ! input . trim ( ) ) return null ;
417+
418+ const formats = timeFormat === 12 ? [ "h:mma" , "HH:mm" ] : [ "HH:mm" , "h:mma" ] ;
419+ const parsed = dayjs ( input , formats , true ) ; // strict parsing
420+
421+ if ( ! parsed . isValid ( ) ) return null ;
422+
423+ const hours = parsed . hour ( ) ;
424+ const minutes = parsed . minute ( ) ;
425+
426+ if ( hours < 0 || hours > 23 || minutes < 0 || minutes > 59 ) {
427+ return null ;
428+ }
429+
430+ return new Date ( new Date ( ) . setUTCHours ( hours , minutes , 0 , 0 ) ) ;
431+ }
432+
407433const LazySelect = ( {
408434 value,
409435 min,
410436 max,
411437 userTimeFormat,
412438 menuPlacement,
439+ innerClassNames,
440+ onChange,
413441 ...props
414442} : Omit < Props < IOption , false , GroupBase < IOption > > , "value" > & {
415443 value : ConfigType ;
@@ -426,31 +454,100 @@ const LazySelect = ({
426454 } , [ filter , value ] ) ;
427455
428456 const [ inputValue , setInputValue ] = React . useState ( "" ) ;
457+ const [ timeInputError , setTimeInputError ] = React . useState ( false ) ;
429458 const defaultFilter = React . useMemo ( ( ) => createFilter ( ) , [ ] ) ;
459+
460+ const handleInputChange = React . useCallback (
461+ ( newValue : string , actionMeta : { action : string } ) => {
462+ setInputValue ( newValue ) ;
463+
464+ if ( actionMeta . action === "input-change" && newValue . trim ( ) ) {
465+ const trimmedValue = newValue . trim ( ) ;
466+
467+ const formats =
468+ userTimeFormat === 12 ? [ "h:mma" , "HH:mm" ] : [ "HH:mm" , "h:mma" ] ;
469+ const parsedTime = dayjs ( trimmedValue , formats , true ) ;
470+ const looksLikeTime = / ^ \d { 1 , 2 } : \d { 2 } ( a | p | a m | p m ) ? $ / i. test ( trimmedValue ) ;
471+
472+ if ( looksLikeTime && ! parsedTime . isValid ( ) ) {
473+ setTimeInputError ( true ) ;
474+ } else if ( parsedTime . isValid ( ) ) {
475+ const parsedDate = parseTimeString ( trimmedValue , userTimeFormat ) ;
476+ if ( parsedDate ) {
477+ const parsedDayjs = dayjs ( parsedDate ) ;
478+ const violatesMin = min ? ! parsedDayjs . isAfter ( min ) : false ;
479+ const violatesMax = max ? ! parsedDayjs . isBefore ( max ) : false ;
480+ setTimeInputError ( Boolean ( violatesMin || violatesMax ) ) ;
481+ } else {
482+ setTimeInputError ( false ) ;
483+ }
484+ } else {
485+ setTimeInputError ( false ) ;
486+ }
487+ } else {
488+ setTimeInputError ( false ) ;
489+ }
490+ } ,
491+ [ userTimeFormat , min , max ]
492+ ) ;
493+
430494 const filteredOptions = React . useMemo ( ( ) => {
431- const regex = / ^ ( \d { 1 , 2 } ) ( a | p | a m | p m ) $ / i;
432- const match = inputValue . replaceAll ( " " , "" ) . match ( regex ) ;
433- if ( ! match ) {
434- return options . filter ( ( option ) =>
435- defaultFilter ( { ...option , data : option . label , value : option . label } , inputValue )
436- ) ;
495+ const dropdownOptions = options . filter ( ( option ) =>
496+ defaultFilter (
497+ { ...option , data : option . label , value : option . label } ,
498+ inputValue
499+ )
500+ ) ;
501+
502+ const trimmedInput = inputValue . trim ( ) ;
503+ if ( trimmedInput ) {
504+ const parsedTime = parseTimeString ( trimmedInput , userTimeFormat ) ;
505+
506+ if ( parsedTime ) {
507+ const parsedDayjs = dayjs ( parsedTime ) ;
508+ // Validate against min/max bounds using same logic as filter function
509+ const withinBounds =
510+ ( ! min || parsedDayjs . isAfter ( min ) ) &&
511+ ( ! max || parsedDayjs . isBefore ( max ) ) ;
512+
513+ if ( withinBounds ) {
514+ const parsedTimestamp = parsedTime . valueOf ( ) ;
515+ const existsInOptions = options . some (
516+ ( option ) => option . value === parsedTimestamp
517+ ) ;
518+
519+ if ( ! existsInOptions ) {
520+ const manualOption : IOption = {
521+ label : dayjs ( parsedTime )
522+ . utc ( )
523+ . format ( userTimeFormat === 12 ? "h:mma" : "HH:mm" ) ,
524+ value : parsedTimestamp ,
525+ } ;
526+ return [ manualOption , ...dropdownOptions ] ;
527+ }
528+ }
529+ }
437530 }
438531
439- const [ , numberPart , periodPart ] = match ;
440- const periodLower = periodPart . toLowerCase ( ) ;
441- const scoredOptions = options
442- . filter ( ( option ) => option . label && option . label . toLowerCase ( ) . includes ( periodLower ) )
443- . map ( ( option ) => {
444- const labelLower = option . label . toLowerCase ( ) ;
445- const index = labelLower . indexOf ( numberPart ) ;
446- const score = index >= 0 ? index + labelLower . length : Infinity ;
447- return { score, option } ;
448- } )
449- . sort ( ( a , b ) => a . score - b . score ) ;
450-
451- const maxScore = scoredOptions [ 0 ] ?. score ;
452- return scoredOptions . filter ( ( item ) => item . score === maxScore ) . map ( ( item ) => item . option ) ;
453- } , [ inputValue , options , defaultFilter ] ) ;
532+ return dropdownOptions ;
533+ } , [ inputValue , options , defaultFilter , userTimeFormat , min , max ] ) ;
534+
535+ const currentValue = dayjs ( value ) . toDate ( ) . valueOf ( ) ;
536+ const currentOption =
537+ options . find ( ( option ) => option . value === currentValue ) ||
538+ ( value
539+ ? {
540+ value : currentValue ,
541+ label : dayjs ( value )
542+ . utc ( )
543+ . format ( userTimeFormat === 12 ? "h:mma" : "HH:mm" ) ,
544+ }
545+ : null ) ;
546+
547+ const errorInnerClassNames : SelectInnerClassNames = {
548+ ...innerClassNames ,
549+ control : cn ( innerClassNames ?. control , timeInputError && "!border-error" ) ,
550+ } ;
454551
455552 return (
456553 < Select
@@ -461,11 +558,16 @@ const LazySelect = ({
461558 if ( ! min && ! max ) filter ( { offset : 0 , limit : 0 } ) ;
462559 } }
463560 menuPlacement = { menuPlacement }
464- value = { options . find ( ( option ) => option . value === dayjs ( value ) . toDate ( ) . valueOf ( ) ) }
561+ value = { currentOption }
465562 onMenuClose = { ( ) => filter ( { current : value } ) }
466- components = { { DropdownIndicator : ( ) => null , IndicatorSeparator : ( ) => null } }
467- onInputChange = { setInputValue }
563+ components = { {
564+ DropdownIndicator : ( ) => null ,
565+ IndicatorSeparator : ( ) => null ,
566+ } }
567+ onInputChange = { handleInputChange }
468568 filterOption = { ( ) => true }
569+ innerClassNames = { errorInnerClassNames }
570+ onChange = { onChange }
469571 { ...props }
470572 />
471573 ) ;
@@ -514,17 +616,34 @@ const useOptions = (timeFormat: number | null) => {
514616 const filter = useCallback (
515617 ( { offset, limit, current } : { offset ?: ConfigType ; limit ?: ConfigType ; current ?: ConfigType } ) => {
516618 if ( current ) {
517- const currentOption = options . find ( ( option ) => option . value === dayjs ( current ) . toDate ( ) . valueOf ( ) ) ;
518- if ( currentOption ) setFilteredOptions ( [ currentOption ] ) ;
619+ const currentValue = dayjs ( current ) . toDate ( ) . valueOf ( ) ;
620+ const currentOption = options . find (
621+ ( option ) => option . value === currentValue
622+ ) ;
623+ if ( currentOption ) {
624+ setFilteredOptions ( [ currentOption ] ) ;
625+ } else {
626+ // Create temporary option for custom time not in predefined options
627+ const customOption : IOption = {
628+ value : currentValue ,
629+ label : dayjs ( current )
630+ . utc ( )
631+ . format ( timeFormat === 12 ? "h:mma" : "HH:mm" ) ,
632+ } ;
633+ setFilteredOptions ( [ customOption ] ) ;
634+ }
519635 } else
520636 setFilteredOptions (
521637 options . filter ( ( option ) => {
522638 const time = dayjs ( option . value ) ;
523- return ( ! limit || time . isBefore ( limit ) ) && ( ! offset || time . isAfter ( offset ) ) ;
639+ return (
640+ ( ! limit || time . isBefore ( limit ) ) &&
641+ ( ! offset || time . isAfter ( offset ) )
642+ ) ;
524643 } )
525644 ) ;
526645 } ,
527- [ options ]
646+ [ options , timeFormat ]
528647 ) ;
529648
530649 return { options : filteredOptions , filter } ;
0 commit comments