@ -668,6 +668,122 @@ export class DateFieldProvider {
}
// ── Segment update routing ───────────────────────────────────────────────
//
// `updateSegment` orchestrates a 3-phase pipeline:
// 1. **Compute next**: run the consumer's `Updater` callback against
// the current segment value to derive `next`.
// 2. **Apply cascade**: when the segment that changed has cross-
// segment implications (month → day clamp, hour → dayPeriod
// derivation, dayPeriod → hour AM/PM flip), patch `prev` in-place
// via a dedicated helper. The helpers mutate `prev` because the
// cascade target is a different segment than the one being
// updated — `newSegmentValues = { ...prev, [part]: next }` then
// captures both the explicit change AND the cascaded ones.
// 3. **Commit**: write `segmentValues`, derive the value from DOM
// order when all required segments are filled, and emit the
// `commit-set`/`commit-clear` sema event when the value changes.
//
// The helpers below isolate each cascade. They take only the data
// they need so they can be reused between the DateAndTime branch and
// the Date-only branch without re-implementing the logic.
/ * *
* Cascade : when the user changes the month , the previous day may
* exceed the new month ' s length ( e . g . day = 31 + new month = Feb → 28 ) .
* Clamp ` prev.day ` so the resulting CalendarDate is valid .
*
* Mutates ` prev ` in place because the cascade target ( ` day ` ) is a
* different segment than the one being updated ( ` month ` ) .
* /
private cascadeMonthToDay (
prev : { day : string | null } ,
nextMonth : string | null ,
dateRef : DateValue
) : void {
if ( nextMonth === null || prev . day === null ) return ;
const date = dateRef . set ( { month : Number.parseInt ( nextMonth ) } ) ;
const daysInMonth = getDaysInMonth ( toDate ( date ) ) ;
const prevDay = Number . parseInt ( prev . day ) ;
if ( prevDay > daysInMonth ) prev . day = ` ${ daysInMonth } ` ;
}
/ * *
* Cascade : when the user changes the hour , the dayPeriod indicator
* may need to flip ( e . g . 11 → 13 crosses noon ) . Derive it from the
* formatter so the displayed AM / PM matches the underlying value .
*
* Mutates ` prev.dayPeriod ` in place .
* /
private cascadeHourToDayPeriod (
prev : { dayPeriod : string | null } ,
nextHour : string | null ,
dateRef : DateValue
) : void {
if ( nextHour === null || prev . dayPeriod === null ) return ;
const dayPeriod = this . formatter . dayPeriod (
toDate ( dateRef . set ( { hour : Number.parseInt ( nextHour ) } ) ) ,
this . resolvedHourCycle
) ;
if ( dayPeriod === 'AM' || dayPeriod === 'PM' ) prev . dayPeriod = dayPeriod ;
}
/ * *
* Cascade : when the user toggles AM / PM , the displayed hour must flip
* 12 h to match the new period ( 1 PM ↔ 13 :00 , etc . ) . Operates against
* the * committed * value ' s true hour so the conversion is reversible
* across multiple toggles .
* /
private cascadeDayPeriodToHour (
prev : { hour : string | null } ,
nextDayPeriod : 'AM' | 'PM' | null
) : void {
const date = this . opts . value . current ;
if ( ! date || ! ( 'hour' in date ) ) return ;
const trueHour = ( date as { hour : number } ) . hour ;
if ( nextDayPeriod === 'AM' && trueHour >= 12 ) prev . hour = ` ${ trueHour - 12 } ` ;
else if ( nextDayPeriod === 'PM' && trueHour < 12 ) prev . hour = ` ${ trueHour + 12 } ` ;
}
/ * *
* Tracks the in - flight value of a segment that has an ` updating `
* staging slot in ` this.states ` . Used by segments where the
* not - yet - committed input matters for typeahead / formatting
* ( ` day ` , ` month ` , ` year ` , ` hour ` , ` minute ` , ` second ` , ` dayPeriod ` ) .
* /
private setSegmentUpdating ( part : string , next : unknown ) : void {
const states = this . states as Record < string , { updating : unknown } | undefined > ;
const slot = states [ part ] ;
if ( slot ) slot . updating = next ;
}
/ * *
* Commit phase : write the new segment object , derive the value from
* DOM order when all required segments are filled , otherwise clear
* to ` undefined ` . Fires ` commit-set ` / ` commit-clear ` only when the
* resulting stringified value differs from the previous one .
* /
private commitSegmentValues (
newSegmentValues : SegmentValueObj ,
previousValue : string | undefined
) : void {
this . segmentValues = newSegmentValues ;
if ( areAllSegmentsFilledDOM ( newSegmentValues , this . fieldNode ) ) {
this . setValue (
getValueFromSegmentsDOM ( {
segmentObj : newSegmentValues ,
fieldNode : this.fieldNode ,
dateRef : this.opts.placeholder.current
} )
) ;
} else {
this . setValue ( undefined ) ;
this . segmentValues = newSegmentValues ;
}
const nextValue = this . opts . value . current ? . toString ( ) ;
if ( previousValue !== nextValue ) {
void this . runtime . trigger ( nextValue === undefined ? 'commit-clear' : 'commit-set' ) ;
}
}
updateSegment = < T extends keyof DateAndTimeSegmentObj > (
part : T ,
@ -690,80 +806,37 @@ export class DateFieldProvider {
if ( isDateAndTimeSegmentObj ( prev ) ) {
const pVal = prev [ part ] ;
const castCb = cb as Updater < DateAndTimeSegmentObj [ T ] > ;
const next = castCb ( pVal ) ;
// Stage the in-flight value for segments with `updating` slots.
this . setSegmentUpdating ( part as string , next ) ;
// Apply cross-segment cascade. Each helper is a no-op when its
// guards fail (null values, missing cross-segment) so the order
// here only matters within a single `part`.
if ( part === 'month' ) {
const next = castCb ( pVal ) as DateAndTimeSegmentObj [ 'month' ] ;
this . states . month . updating = next ;
if ( next !== null && prev . day !== null ) {
const date = dateRef . set ( { month : Number.parseInt ( next ) } ) ;
const daysInMonth = getDaysInMonth ( toDate ( date ) ) ;
const prevDay = Number . parseInt ( prev . day ) ;
if ( prevDay > daysInMonth ) prev . day = ` ${ daysInMonth } ` ;
}
newSegmentValues = { . . . prev , [ part ] : next } ;
} else if ( part === 'dayPeriod' ) {
const next = castCb ( pVal ) as DateAndTimeSegmentObj [ 'dayPeriod' ] ;
this . states . dayPeriod . updating = next ;
const date = this . opts . value . current ;
if ( date && 'hour' in date ) {
const trueHour = date . hour ;
if ( next === 'AM' && trueHour >= 12 ) prev . hour = ` ${ trueHour - 12 } ` ;
else if ( next === 'PM' && trueHour < 12 ) prev . hour = ` ${ trueHour + 12 } ` ;
}
newSegmentValues = { . . . prev , [ part ] : next } ;
this . cascadeMonthToDay ( prev , next as string | null , dateRef ) ;
} else if ( part === 'hour' ) {
const next = castCb ( pVal ) as DateAndTimeSegmentObj [ 'hour' ] ;
this . states . hour . updating = next ;
if ( next !== null && prev . dayPeriod !== null ) {
const dayPeriod = this . formatter . dayPeriod (
toDate ( dateRef . set ( { hour : Number.parseInt ( next ) } ) ) ,
this . resolvedHourCycle
) ;
if ( dayPeriod === 'AM' || dayPeriod === 'PM' ) prev . dayPeriod = dayPeriod ;
this . cascadeHourToDayPeriod ( prev , next as string | null , dateRef ) ;
} else if ( part === 'dayPeriod' ) {
this . cascadeDayPeriodToHour ( prev , next as 'AM' | 'PM' | null ) ;
}
newSegmentValues = { . . . prev , [ part ] : next } ;
} else if ( part === 'minute' || part === 'second' || part === 'year' || part === 'day' ) {
const next = castCb ( pVal ) ;
( this . states as Record < string , { updating : unknown } > ) [ part ] . updating = next ;
newSegmentValues = { . . . prev , [ part ] : next } ;
} else {
const next = castCb ( pVal ) ;
newSegmentValues = { . . . prev , [ part ] : next } ;
}
} else if ( isDateSegmentPart ( part ) ) {
const pVal = prev [ part as DateSegmentPart ] ;
const castCb = cb as unknown as Updater < DateSegmentObj [ DateSegmentPart ] > ;
const next = castCb ( pVal ) ;
if ( part === 'month' && next !== null && prev . day !== null ) {
this . states . month . updating = next ;
const date = dateRef . set ( { month : Number.parseInt ( next ) } ) ;
const daysInMonth = getDaysInMonth ( toDate ( date ) ) ;
if ( Number . parseInt ( prev . day ) > daysInMonth ) prev . day = ` ${ daysInMonth } ` ;
newSegmentValues = { . . . prev , [ part ] : next } ;
} else if ( part === 'year' || part === 'day' ) {
( this . states as Record < string , { updating : unknown } > ) [ part ] . updating = next ;
newSegmentValues = { . . . prev , [ part ] : next } ;
} else {
newSegmentValues = { . . . prev , [ part ] : next } ;
}
}
this . segmentValues = newSegmentValues ;
if ( areAllSegmentsFilledDOM ( newSegmentValues , this . fieldNode ) ) {
this . setValue (
getValueFromSegmentsDOM ( {
segmentObj : newSegmentValues ,
fieldNode : this.fieldNode ,
dateRef : this.opts.placeholder.current
} )
) ;
} else {
this . setValue ( undefined ) ;
this . segmentValues = newSegmentValues ;
this . setSegmentUpdating ( part as string , next ) ;
if ( part === 'month' ) {
this . cascadeMonthToDay ( prev , next , dateRef ) ;
}
const nextValue = this . opts . value . current ? . toString ( ) ;
if ( previousValue !== nextValue ) {
void this . runtime . trigger ( nextValue === undefined ? 'commit-clear' : 'commit-set' ) ;
newSegmentValues = { . . . prev , [ part ] : next } ;
}
this . commitSegmentValues ( newSegmentValues , previousValue ) ;
} ;
handleSegmentClick = ( e : SomaMouseEvent ) = > {