11'use client' ;
22
33import type React from 'react' ;
4- import { createContext , useCallback , useContext , useEffect , useMemo , useState } from 'react' ;
4+ import { createContext , useCallback , useContext , useEffect , useMemo , useRef , useState } from 'react' ;
5+ import { Calendar } from '@/components/ui/calendar' ;
56import {
67 Command ,
78 CommandEmpty ,
@@ -21,7 +22,7 @@ import {Popover, PopoverContent, PopoverTrigger} from '@/components/ui/popover';
2122import { Switch } from '@/components/ui/switch' ;
2223import { Tooltip , TooltipContent , TooltipTrigger } from '@/components/ui/tooltip' ;
2324import { cva , type VariantProps } from 'class-variance-authority' ;
24- import { AlertCircle , Check , Loader2 , Plus , X } from 'lucide-react' ;
25+ import { AlertCircle , Calendar as CalendarIcon , Check , Loader2 , Plus , X } from 'lucide-react' ;
2526import { cn } from '@/lib/utils' ;
2627
2728// i18n Configuration Interface
@@ -642,6 +643,214 @@ function FilterInput<T = unknown>({
642643 ) ;
643644}
644645
646+ // Parses an HTML-date-input value (YYYY-MM-DD) into a local-time Date so it
647+ // matches what the native control would have produced. Returns undefined for
648+ // empty / unparseable input rather than throwing.
649+ const parseFilterDateValue = ( value : string ) : Date | undefined => {
650+ const match = / ^ ( \d { 4 } ) - ( \d { 2 } ) - ( \d { 2 } ) $ / . exec ( value ) ;
651+ if ( ! match ) {
652+ return undefined ;
653+ }
654+ const [ , yearPart , monthPart , dayPart ] = match ;
655+ const year = Number ( yearPart ) ;
656+ const month = Number ( monthPart ) ;
657+ const day = Number ( dayPart ) ;
658+ const date = new Date ( year , month - 1 , day ) ;
659+
660+ if (
661+ date . getFullYear ( ) !== year ||
662+ date . getMonth ( ) !== month - 1 ||
663+ date . getDate ( ) !== day
664+ ) {
665+ return undefined ;
666+ }
667+
668+ return date ;
669+ } ;
670+
671+ const formatFilterDateValue = ( date : Date | undefined ) : string => {
672+ if ( ! date ) {
673+ return '' ;
674+ }
675+ const year = date . getFullYear ( ) ;
676+ const month = String ( date . getMonth ( ) + 1 ) . padStart ( 2 , '0' ) ;
677+ const day = String ( date . getDate ( ) ) . padStart ( 2 , '0' ) ;
678+ return `${ year } -${ month } -${ day } ` ;
679+ } ;
680+
681+ interface FilterDatePickerProps < T = unknown > {
682+ field ?: FilterFieldConfig < T > ;
683+ value : string ;
684+ onChange : ( value : string ) => void ;
685+ className ?: string ;
686+ }
687+
688+ // Composes a text input for YYYY-MM-DD values with a Shade Calendar popover.
689+ // Avoid using <input type="date"> here: Safari opens its native date picker
690+ // from clicks inside the text area even when the calendar indicator is hidden.
691+ function FilterDatePicker < T = unknown > ( {
692+ field,
693+ value,
694+ onChange,
695+ className
696+ } : FilterDatePickerProps < T > ) {
697+ const context = useFilterContext ( ) ;
698+ const [ open , setOpen ] = useState ( false ) ;
699+ const parsed = useMemo ( ( ) => parseFilterDateValue ( value ) , [ value ] ) ;
700+ const [ month , setMonth ] = useState < Date | undefined > ( parsed ) ;
701+ const inputRef = useRef < HTMLInputElement > ( null ) ;
702+ const lastLocalCommitRef = useRef ( value ) ;
703+ // Local buffer for the input's value so the controlled element follows the
704+ // user's segment-edit state instead of the filter state. This insulates the
705+ // input from upstream re-renders triggered by URL roundtrips on Comments —
706+ // each keystroke updates `localValue` (which matches what the browser put in
707+ // the DOM), so React never has to force the DOM back to the committed
708+ // value mid-edit and the segment-edit cursor stays intact.
709+ const [ localValue , setLocalValue ] = useState ( value ) ;
710+
711+ useEffect ( ( ) => {
712+ if ( parsed ) {
713+ setMonth ( parsed ) ;
714+ }
715+ } , [ parsed ] ) ;
716+
717+ // Sync the buffer from the committed filter value only when the user
718+ // isn't editing — calendar picks, "Clear filters", URL deep-links, etc.
719+ useEffect ( ( ) => {
720+ if ( value === lastLocalCommitRef . current ) {
721+ return ;
722+ }
723+
724+ if ( document . activeElement !== inputRef . current ) {
725+ setLocalValue ( value ) ;
726+ lastLocalCommitRef . current = value ;
727+ }
728+ } , [ value ] ) ;
729+
730+ const notifyInputChange = ( nextValue : string , input : HTMLInputElement | null = inputRef . current ) => {
731+ if ( ! field ?. onInputChange || ! input ) {
732+ return ;
733+ }
734+
735+ field . onInputChange ( {
736+ target : { ...input , value : nextValue } ,
737+ currentTarget : { ...input , value : nextValue }
738+ } as React . ChangeEvent < HTMLInputElement > ) ;
739+ } ;
740+
741+ const handleInputChange = ( e : React . ChangeEvent < HTMLInputElement > ) => {
742+ setLocalValue ( e . target . value ) ;
743+ } ;
744+
745+ const handleInputBlur = ( e : React . FocusEvent < HTMLInputElement > ) => {
746+ const inputValue = e . target . value ;
747+ const parsedInputValue = parseFilterDateValue ( inputValue ) ;
748+ const dateValue = inputValue && ! parsedInputValue ? formatFilterDateValue ( new Date ( ) ) : inputValue ;
749+
750+ if ( parsedInputValue ) {
751+ setMonth ( parsedInputValue ) ;
752+ } else if ( dateValue ) {
753+ setMonth ( parseFilterDateValue ( dateValue ) ) ;
754+ }
755+
756+ if ( dateValue !== inputValue ) {
757+ if ( inputRef . current ) {
758+ inputRef . current . value = dateValue ;
759+ }
760+ setLocalValue ( dateValue ) ;
761+ }
762+
763+ if ( dateValue !== value ) {
764+ lastLocalCommitRef . current = dateValue ;
765+ onChange ( dateValue ) ;
766+ }
767+ notifyInputChange ( dateValue , e . target ) ;
768+ } ;
769+
770+ const handleSelect = ( date : Date | undefined ) => {
771+ if ( ! date ) {
772+ lastLocalCommitRef . current = '' ;
773+ if ( inputRef . current ) {
774+ inputRef . current . value = '' ;
775+ }
776+ setLocalValue ( '' ) ;
777+ onChange ( '' ) ;
778+ notifyInputChange ( '' ) ;
779+ return ;
780+ }
781+ const formatted = formatFilterDateValue ( date ) ;
782+ lastLocalCommitRef . current = formatted ;
783+ if ( inputRef . current ) {
784+ inputRef . current . value = formatted ;
785+ }
786+ setMonth ( date ) ;
787+ setLocalValue ( formatted ) ;
788+ onChange ( formatted ) ;
789+ notifyInputChange ( formatted ) ;
790+ setOpen ( false ) ;
791+ } ;
792+
793+ return (
794+ < div
795+ className = { cn (
796+ 'w-32' ,
797+ filterInputVariants ( { variant : context . variant , size : context . size , cursorPointer : false } ) ,
798+ className
799+ ) }
800+ data-slot = "filters-input-wrapper"
801+ >
802+ { field ?. prefix && (
803+ < div
804+ className = { filterFieldAddonVariants ( { variant : context . variant , size : context . size } ) }
805+ data-slot = "filters-prefix"
806+ >
807+ { field . prefix }
808+ </ div >
809+ ) }
810+ < div className = "flex w-full min-w-0 items-stretch" >
811+ < input
812+ ref = { inputRef }
813+ autoComplete = "off"
814+ className = "w-full min-w-0 bg-transparent outline-hidden dark:!bg-transparent"
815+ data-slot = "filters-input"
816+ inputMode = "numeric"
817+ pattern = "\d{4}-\d{2}-\d{2}"
818+ placeholder = "YYYY-MM-DD"
819+ type = "text"
820+ value = { localValue }
821+ onBlur = { handleInputBlur }
822+ onChange = { handleInputChange }
823+ />
824+ </ div >
825+ < Popover open = { open } onOpenChange = { setOpen } >
826+ < PopoverTrigger asChild >
827+ < button
828+ aria-label = "Open calendar"
829+ className = { cn (
830+ filterFieldAddonVariants ( { variant : context . variant , size : context . size } ) ,
831+ 'cursor-pointer text-muted-foreground transition-colors hover:text-foreground'
832+ ) }
833+ data-slot = "filters-suffix"
834+ type = "button"
835+ >
836+ < CalendarIcon className = "size-3.5" />
837+ </ button >
838+ </ PopoverTrigger >
839+ < PopoverContent align = "center" className = "w-auto overflow-hidden p-0" sideOffset = { 4 } >
840+ < Calendar
841+ captionLayout = "dropdown"
842+ mode = "single"
843+ month = { month }
844+ selected = { parsed }
845+ onMonthChange = { setMonth }
846+ onSelect = { handleSelect }
847+ />
848+ </ PopoverContent >
849+ </ Popover >
850+ </ div >
851+ ) ;
852+ }
853+
645854interface FilterRemoveButtonProps
646855 extends React . ButtonHTMLAttributes < HTMLButtonElement > ,
647856 VariantProps < typeof filterRemoveButtonVariants > {
@@ -1733,27 +1942,23 @@ function FilterValueSelector<T = unknown>({field, values, onChange, operator}: F
17331942 cursorPointer : context . cursorPointer
17341943 } ) }
17351944 >
1736- < FilterInput
1737- className = { cn ( 'w-24 max-w-full' , field . className ) }
1945+ < FilterDatePicker
1946+ className = { cn ( 'max-w-full' , field . className ) }
17381947 field = { field }
1739- type = "date"
17401948 value = { startDate }
1741- onChange = { e => onChange ( [ e . target . value , endDate ] as T [ ] ) }
1742- onInputChange = { field . onInputChange }
1949+ onChange = { v => onChange ( [ v , endDate ] as T [ ] ) }
17431950 />
17441951 < div
17451952 className = { filterFieldBetweenVariants ( { variant : context . variant , size : context . size } ) }
17461953 data-slot = "filters-between"
17471954 >
17481955 { context . i18n . to }
17491956 </ div >
1750- < FilterInput
1751- className = { cn ( 'w-24 max-w-full' , field . className ) }
1957+ < FilterDatePicker
1958+ className = { cn ( 'max-w-full' , field . className ) }
17521959 field = { field }
1753- type = "date"
17541960 value = { endDate }
1755- onChange = { e => onChange ( [ startDate , e . target . value ] as T [ ] ) }
1756- onInputChange = { field . onInputChange }
1961+ onChange = { v => onChange ( [ startDate , v ] as T [ ] ) }
17571962 />
17581963 </ div >
17591964 ) ;
@@ -1824,13 +2029,11 @@ function FilterValueSelector<T = unknown>({field, values, onChange, operator}: F
18242029
18252030 if ( field . type === 'date' ) {
18262031 return (
1827- < FilterInput
1828- className = { cn ( 'w-16' , field . className ) }
2032+ < FilterDatePicker
2033+ className = { field . className }
18292034 field = { field }
1830- type = "date"
18312035 value = { ( values [ 0 ] as string ) || '' }
1832- onChange = { e => onChange ( [ e . target . value ] as T [ ] ) }
1833- onInputChange = { field . onInputChange }
2036+ onChange = { v => onChange ( [ v ] as T [ ] ) }
18342037 />
18352038 ) ;
18362039 }
0 commit comments