Calendar year dropdown range derived from fromDate/toDate (WEB-1312)
Version: 0.9.1 ยท Type: ๐ Bug Fix
Problemโ
The Calendar / DatePicker year dropdown listed currentYear ยฑ 50 regardless of the fromDate / toDate props passed by the caller. Passing fromDate={today} (to restrict selection to today and later) still showed decades of past years as if they were selectable โ picking one did jump the calendar there, but every day cell in that month was disabled, which reads as broken to users.
The same problem existed for DatePicker's disabledDate prop (passed through as Calendar's disabled matcher): the design-site "Disabled Dates" demo uses disabledDate={{ before: new Date() }}, and the year dropdown still listed years back to 1970 as if selectable.
The prev/next month navigation arrows had the same blind spot: they had no boundary awareness at all, so with fromDate={today} (or an equivalent disabled={{ before: today }}) the prev arrow could still be clicked all the way back past 1969 โ beyond even what the (now-fixed) year dropdown offers.
Root causeโ
fromDate / toDate were only consumed in Calendar.tsx to disable individual day cells. CalendarMonthCaption built its year list from module-level constants (CURRENT_YEAR ยฑ 50) with no knowledge of the boundaries, and the month dropdown had no boundary awareness either.
disabled's { before } / { after } matcher forms are semantically equivalent to fromDate / toDate (see matchDate in Calendar.tsx) but were never fed into the same derivation, so the same broken-looking year range persisted for any caller using disabledDate instead of fromDate/toDate.
The navigation arrows' goToPrevMonth / goToNextMonth handlers had no bound checks at all โ they always shifted currentMonth by one month regardless of minDate / maxDate.
Changed Filesโ
packages/design/src/components/Calendar/CalendarMonthCaption.tsxpackages/design/src/components/Calendar/Calendar.tsxpackages/design/src/components/Calendar/styles.tspackages/design/src/components/Calendar/__tests__/Calendar.test.tsx
Changesโ
CalendarMonthCaptionnow derives the year dropdown's floor fromfromDate(falls back to a newDEFAULT_START_YEARof1970when absent) and its ceiling fromtoDate(falls back toDEFAULT_END_YEAR,currentYear + 50, when absent), clipping the list instead of disabling entries. If the derived floor ends up above the ceiling, the list degrades to that single year rather than throwing or rendering empty.- On the boundary year, the month dropdown disables months before
fromDate's month (or aftertoDate's month) while still rendering all 12 entries, so item positions never shift. - Selecting a year now clamps the target month into that year's valid range before calling
goToMonth, so switching to a boundary year never lands on a fully-disabled month. CalendarforwardsfromDate/toDatetoCalendarMonthCaption(previously only used for day-cell disabling).- Added unit tests covering the default 1970 floor,
fromDate/toDate-derived list bounds, boundary-year month disabling, and the year-selection month clamp. Calendarnow also normalizes thedisabledmatcher entry through a newresolveSelectableBoundshelper:{ before }/{ after }matchers (single or array) are folded into the sameminDate/maxDatepair passed toCalendarMonthCaption. Since disabled-day checks OR multiple matchers together, the selectable range is their intersection โ the floor is the max offromDateand every{ before }value, the ceiling is the min oftoDateand every{ after }value. Non-monotonic matchers (bareDate,{ from, to }, function predicates) are intentionally ignored since they don't yield a reliable bound.- Added unit tests covering the
disabled-matcher-driven bounds: single{ before }/{ after }, an array converging both bounds,fromDate/toDatevs.disableddisagreeing (stricter bound wins), and non-monotonic matchers (function, bareDate,{ from, to }) leaving the default 1970 floor untouched. - The prev/next navigation arrows now disable themselves once the displayed month range reaches the resolved
minDate/maxDate, compared at year-month granularity via a newtoYearMonthIndexhelper: the prev arrow disables when the first displayed month is at or beforeminDate's month, the next arrow disables when the last displayed month โaddMonths(currentMonth, numberOfMonths - 1), soRangePicker's dual-calendar mode is accounted for โ is at or aftermaxDate's month. All nav buttons (including both calendars innavPerMonthmode) share the same disabled state since they drive the samecurrentMonthoffset. goToPrevMonth/goToNextMonthearly-return when their respective disabled flag is set, so keyboard or programmatic invocation can't bypass thedisabledattribute.CALENDAR_NAV_BUTTON_CLASSgaineddisabled:cursor-not-allowed,disabled:text-(--Labels-Disabled), anddisabled:hover:bg-transparent disabled:hover:text-(--Labels-Disabled)to suppress the hover styles that would otherwise leak through on a disabled button (same pattern as the Button component's disabled state).- Added unit tests covering the arrow-disabling boundary: no-constraint baseline,
fromDate/toDatereaching the displayed month, thedisabled={{ before }}matcher parity withfromDate, clicking a disabled arrow not firingonMonthChange,numberOfMonths={2}disabling based on the last visible month rather than the first, and non-boundary arrows remaining clickable.
Impactโ
- Callers passing
fromDate/toDatenow get a year dropdown that matches the actually selectable range. - Callers not passing
fromDate/toDatekeep the samecurrentYear + 50upper bound; the lower bound is now a fixed1970instead of a rollingcurrentYear - 50. - Callers using
disabledDate(Calendar'sdisabledprop) with{ before }/{ after }matchers get the same year/month dropdown convergence asfromDate/toDate, including the design-site "Disabled Dates" demo. - The prev/next navigation arrows can no longer be clicked past the resolved
minDate/maxDate, whether that bound comes fromfromDate/toDateor adisabled={{ before }}/{ after }matcher.