Skip to main content

Calendar year dropdown range derived from fromDate/toDate (WEB-1312)

Version: 0.9.1 ยท Type: ๐Ÿ› Bug Fix

WEB-1312 ยท reported from FISH-85

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.tsx
  • packages/design/src/components/Calendar/Calendar.tsx
  • packages/design/src/components/Calendar/styles.ts
  • packages/design/src/components/Calendar/__tests__/Calendar.test.tsx

Changesโ€‹

  1. CalendarMonthCaption now derives the year dropdown's floor from fromDate (falls back to a new DEFAULT_START_YEAR of 1970 when absent) and its ceiling from toDate (falls back to DEFAULT_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.
  2. On the boundary year, the month dropdown disables months before fromDate's month (or after toDate's month) while still rendering all 12 entries, so item positions never shift.
  3. 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.
  4. Calendar forwards fromDate / toDate to CalendarMonthCaption (previously only used for day-cell disabling).
  5. Added unit tests covering the default 1970 floor, fromDate/toDate-derived list bounds, boundary-year month disabling, and the year-selection month clamp.
  6. Calendar now also normalizes the disabled matcher entry through a new resolveSelectableBounds helper: { before } / { after } matchers (single or array) are folded into the same minDate/maxDate pair passed to CalendarMonthCaption. Since disabled-day checks OR multiple matchers together, the selectable range is their intersection โ€” the floor is the max of fromDate and every { before } value, the ceiling is the min of toDate and every { after } value. Non-monotonic matchers (bare Date, { from, to }, function predicates) are intentionally ignored since they don't yield a reliable bound.
  7. Added unit tests covering the disabled-matcher-driven bounds: single { before } / { after }, an array converging both bounds, fromDate/toDate vs. disabled disagreeing (stricter bound wins), and non-monotonic matchers (function, bare Date, { from, to }) leaving the default 1970 floor untouched.
  8. 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 new toYearMonthIndex helper: the prev arrow disables when the first displayed month is at or before minDate's month, the next arrow disables when the last displayed month โ€” addMonths(currentMonth, numberOfMonths - 1), so RangePicker's dual-calendar mode is accounted for โ€” is at or after maxDate's month. All nav buttons (including both calendars in navPerMonth mode) share the same disabled state since they drive the same currentMonth offset.
  9. goToPrevMonth / goToNextMonth early-return when their respective disabled flag is set, so keyboard or programmatic invocation can't bypass the disabled attribute.
  10. CALENDAR_NAV_BUTTON_CLASS gained disabled:cursor-not-allowed, disabled:text-(--Labels-Disabled), and disabled: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).
  11. Added unit tests covering the arrow-disabling boundary: no-constraint baseline, fromDate/toDate reaching the displayed month, the disabled={{ before }} matcher parity with fromDate, clicking a disabled arrow not firing onMonthChange, numberOfMonths={2} disabling based on the last visible month rather than the first, and non-boundary arrows remaining clickable.

Impactโ€‹

  • Callers passing fromDate / toDate now get a year dropdown that matches the actually selectable range.
  • Callers not passing fromDate / toDate keep the same currentYear + 50 upper bound; the lower bound is now a fixed 1970 instead of a rolling currentYear - 50.
  • Callers using disabledDate (Calendar's disabled prop) with { before } / { after } matchers get the same year/month dropdown convergence as fromDate / 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 from fromDate/toDate or a disabled={{ before }}/{ after } matcher.