@transferwise/components 0.0.0-experimental-0df72a3 → 0.0.0-experimental-fe160ab

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/build/Calendar/Calendar.constants.js +10 -0
  2. package/build/Calendar/Calendar.constants.js.map +1 -0
  3. package/build/Calendar/Calendar.constants.mjs +8 -0
  4. package/build/Calendar/Calendar.constants.mjs.map +1 -0
  5. package/build/Calendar/Calendar.js +116 -16
  6. package/build/Calendar/Calendar.js.map +1 -1
  7. package/build/Calendar/Calendar.messages.js +12 -0
  8. package/build/Calendar/Calendar.messages.js.map +1 -1
  9. package/build/Calendar/Calendar.messages.mjs +12 -0
  10. package/build/Calendar/Calendar.messages.mjs.map +1 -1
  11. package/build/Calendar/Calendar.mjs +117 -17
  12. package/build/Calendar/Calendar.mjs.map +1 -1
  13. package/build/Calendar/DateHeader/DateHeader.js +5 -1
  14. package/build/Calendar/DateHeader/DateHeader.js.map +1 -1
  15. package/build/Calendar/DateHeader/DateHeader.mjs +5 -1
  16. package/build/Calendar/DateHeader/DateHeader.mjs.map +1 -1
  17. package/build/Calendar/DayCalendar/DayCalendar.js +20 -2
  18. package/build/Calendar/DayCalendar/DayCalendar.js.map +1 -1
  19. package/build/Calendar/DayCalendar/DayCalendar.mjs +20 -2
  20. package/build/Calendar/DayCalendar/DayCalendar.mjs.map +1 -1
  21. package/build/Calendar/DayCalendar/Table/DayCalendarTable.js +60 -19
  22. package/build/Calendar/DayCalendar/Table/DayCalendarTable.js.map +1 -1
  23. package/build/Calendar/DayCalendar/Table/DayCalendarTable.mjs +60 -19
  24. package/build/Calendar/DayCalendar/Table/DayCalendarTable.mjs.map +1 -1
  25. package/build/Calendar/MonthCalendar/MonthCalendar.js +6 -0
  26. package/build/Calendar/MonthCalendar/MonthCalendar.js.map +1 -1
  27. package/build/Calendar/MonthCalendar/MonthCalendar.mjs +6 -0
  28. package/build/Calendar/MonthCalendar/MonthCalendar.mjs.map +1 -1
  29. package/build/Calendar/MonthCalendar/Table/MonthCalendarTable.js +8 -3
  30. package/build/Calendar/MonthCalendar/Table/MonthCalendarTable.js.map +1 -1
  31. package/build/Calendar/MonthCalendar/Table/MonthCalendarTable.mjs +8 -3
  32. package/build/Calendar/MonthCalendar/Table/MonthCalendarTable.mjs.map +1 -1
  33. package/build/Calendar/TableLink/TableLink.js +91 -0
  34. package/build/Calendar/TableLink/TableLink.js.map +1 -0
  35. package/build/Calendar/TableLink/TableLink.mjs +87 -0
  36. package/build/Calendar/TableLink/TableLink.mjs.map +1 -0
  37. package/build/Calendar/YearCalendar/Table/YearCalendarTable.js +8 -3
  38. package/build/Calendar/YearCalendar/Table/YearCalendarTable.js.map +1 -1
  39. package/build/Calendar/YearCalendar/Table/YearCalendarTable.mjs +8 -3
  40. package/build/Calendar/YearCalendar/Table/YearCalendarTable.mjs.map +1 -1
  41. package/build/Calendar/YearCalendar/YearCalendar.js +7 -0
  42. package/build/Calendar/YearCalendar/YearCalendar.js.map +1 -1
  43. package/build/Calendar/YearCalendar/YearCalendar.mjs +7 -0
  44. package/build/Calendar/YearCalendar/YearCalendar.mjs.map +1 -1
  45. package/build/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.js +33 -0
  46. package/build/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.js.map +1 -0
  47. package/build/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.mjs +31 -0
  48. package/build/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.mjs.map +1 -0
  49. package/build/Calendar/_helpers/isDateDisabled/isDateDisabled.js +40 -0
  50. package/build/Calendar/_helpers/isDateDisabled/isDateDisabled.js.map +1 -0
  51. package/build/Calendar/_helpers/isDateDisabled/isDateDisabled.mjs +38 -0
  52. package/build/Calendar/_helpers/isDateDisabled/isDateDisabled.mjs.map +1 -0
  53. package/build/DateLookup/DateLookup.js +20 -9
  54. package/build/DateLookup/DateLookup.js.map +1 -1
  55. package/build/DateLookup/DateLookup.mjs +20 -9
  56. package/build/DateLookup/DateLookup.mjs.map +1 -1
  57. package/build/i18n/en.json +4 -0
  58. package/build/i18n/en.json.js +4 -0
  59. package/build/i18n/en.json.js.map +1 -1
  60. package/build/i18n/en.json.mjs +4 -0
  61. package/build/i18n/en.json.mjs.map +1 -1
  62. package/build/i18n/hu.json +1 -1
  63. package/build/i18n/hu.json.js +1 -1
  64. package/build/i18n/hu.json.mjs +1 -1
  65. package/build/index.js +2 -2
  66. package/build/index.mjs +1 -1
  67. package/build/main.css +123 -43
  68. package/build/styles/Calendar/Calendar.css +112 -37
  69. package/build/styles/Calendar/TableLink/TableLink.css +115 -0
  70. package/build/styles/main.css +123 -43
  71. package/build/types/Calendar/Calendar.constants.d.ts +2 -0
  72. package/build/types/Calendar/Calendar.constants.d.ts.map +1 -0
  73. package/build/types/Calendar/Calendar.d.ts +16 -3
  74. package/build/types/Calendar/Calendar.d.ts.map +1 -1
  75. package/build/types/Calendar/Calendar.messages.d.ts +20 -0
  76. package/build/types/Calendar/Calendar.messages.d.ts.map +1 -1
  77. package/build/types/Calendar/Calendar.types.d.ts +32 -0
  78. package/build/types/Calendar/Calendar.types.d.ts.map +1 -0
  79. package/build/types/Calendar/DateHeader/DateHeader.d.ts +2 -0
  80. package/build/types/Calendar/DateHeader/DateHeader.d.ts.map +1 -1
  81. package/build/types/Calendar/DayCalendar/DayCalendar.d.ts +7 -1
  82. package/build/types/Calendar/DayCalendar/DayCalendar.d.ts.map +1 -1
  83. package/build/types/Calendar/DayCalendar/Table/DayCalendarTable.d.ts +6 -0
  84. package/build/types/Calendar/DayCalendar/Table/DayCalendarTable.d.ts.map +1 -1
  85. package/build/types/Calendar/MonthCalendar/MonthCalendar.d.ts +1 -0
  86. package/build/types/Calendar/MonthCalendar/MonthCalendar.d.ts.map +1 -1
  87. package/build/types/Calendar/MonthCalendar/Table/MonthCalendarTable.d.ts +2 -1
  88. package/build/types/Calendar/MonthCalendar/Table/MonthCalendarTable.d.ts.map +1 -1
  89. package/build/types/Calendar/{tableLink → TableLink}/TableLink.d.ts +5 -1
  90. package/build/types/Calendar/TableLink/TableLink.d.ts.map +1 -0
  91. package/build/types/Calendar/TableLink/index.d.ts.map +1 -0
  92. package/build/types/Calendar/YearCalendar/Table/YearCalendarTable.d.ts +2 -1
  93. package/build/types/Calendar/YearCalendar/Table/YearCalendarTable.d.ts.map +1 -1
  94. package/build/types/Calendar/YearCalendar/YearCalendar.d.ts +1 -0
  95. package/build/types/Calendar/YearCalendar/YearCalendar.d.ts.map +1 -1
  96. package/build/types/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.d.ts +3 -0
  97. package/build/types/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.d.ts.map +1 -0
  98. package/build/types/Calendar/_helpers/index.d.ts +3 -0
  99. package/build/types/Calendar/_helpers/index.d.ts.map +1 -0
  100. package/build/types/Calendar/_helpers/isDateDisabled/isDateDisabled.d.ts +3 -0
  101. package/build/types/Calendar/_helpers/isDateDisabled/isDateDisabled.d.ts.map +1 -0
  102. package/build/types/Calendar/index.d.ts +1 -0
  103. package/build/types/Calendar/index.d.ts.map +1 -1
  104. package/build/types/DateLookup/DateLookup.d.ts +24 -5
  105. package/build/types/DateLookup/DateLookup.d.ts.map +1 -1
  106. package/build/types/DateLookup/index.d.ts +1 -0
  107. package/build/types/DateLookup/index.d.ts.map +1 -1
  108. package/build/types/index.d.ts +3 -2
  109. package/build/types/index.d.ts.map +1 -1
  110. package/build/types/test-utils/index.d.ts +8 -0
  111. package/build/types/test-utils/index.d.ts.map +1 -1
  112. package/package.json +1 -1
  113. package/src/Calendar/Calendar.constants.ts +5 -0
  114. package/src/Calendar/Calendar.css +112 -37
  115. package/src/Calendar/Calendar.disabledDates.test.tsx +593 -0
  116. package/src/Calendar/Calendar.less +5 -35
  117. package/src/Calendar/Calendar.messages.ts +21 -0
  118. package/src/Calendar/Calendar.test.tsx +310 -28
  119. package/src/Calendar/Calendar.tsx +159 -18
  120. package/src/Calendar/Calendar.types.ts +35 -0
  121. package/src/Calendar/DateHeader/DateHeader.tsx +6 -0
  122. package/src/Calendar/DayCalendar/DayCalendar.tsx +39 -1
  123. package/src/Calendar/DayCalendar/Table/DayCalendarTable.tsx +67 -27
  124. package/src/Calendar/MonthCalendar/MonthCalendar.tsx +8 -0
  125. package/src/Calendar/MonthCalendar/Table/MonthCalendarTable.tsx +15 -3
  126. package/src/Calendar/TableLink/TableLink.css +115 -0
  127. package/src/Calendar/TableLink/TableLink.less +68 -0
  128. package/src/Calendar/TableLink/TableLink.tsx +112 -0
  129. package/src/Calendar/YearCalendar/Table/YearCalendarTable.tsx +15 -3
  130. package/src/Calendar/YearCalendar/YearCalendar.tsx +9 -0
  131. package/src/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.test.ts +98 -0
  132. package/src/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.ts +45 -0
  133. package/src/Calendar/_helpers/index.ts +2 -0
  134. package/src/Calendar/_helpers/isDateDisabled/isDateDisabled.test.ts +169 -0
  135. package/src/Calendar/_helpers/isDateDisabled/isDateDisabled.ts +59 -0
  136. package/src/Calendar/_stories/Calendar.accessibility.docs.mdx +118 -0
  137. package/src/Calendar/_stories/Calendar.story.tsx +138 -4
  138. package/src/Calendar/_stories/Calendar.test.story.tsx +114 -0
  139. package/src/Calendar/index.ts +7 -0
  140. package/src/DateLookup/DateLookup.disabledDates.test.tsx +293 -0
  141. package/src/DateLookup/DateLookup.test.tsx +34 -37
  142. package/src/DateLookup/DateLookup.tsx +37 -13
  143. package/src/DateLookup/_stories/DateLookup.accessibility.docs.mdx +108 -0
  144. package/src/DateLookup/_stories/DateLookup.story.tsx +124 -2
  145. package/src/DateLookup/index.ts +6 -0
  146. package/src/i18n/en.json +4 -0
  147. package/src/i18n/hu.json +1 -1
  148. package/src/index.ts +3 -2
  149. package/src/main.css +123 -43
  150. package/build/Calendar/tableLink/TableLink.js +0 -61
  151. package/build/Calendar/tableLink/TableLink.js.map +0 -1
  152. package/build/Calendar/tableLink/TableLink.mjs +0 -57
  153. package/build/Calendar/tableLink/TableLink.mjs.map +0 -1
  154. package/build/types/Calendar/tableLink/TableLink.d.ts.map +0 -1
  155. package/build/types/Calendar/tableLink/index.d.ts.map +0 -1
  156. package/src/Calendar/tableLink/TableLink.tsx +0 -77
  157. /package/build/types/Calendar/{tableLink → TableLink}/index.d.ts +0 -0
  158. /package/src/Calendar/{tableLink → TableLink}/index.ts +0 -0
@@ -1,40 +1,85 @@
1
- import { useState, useCallback, useEffect, KeyboardEvent } from 'react';
1
+ import { useState, useCallback, useEffect, useId, useRef, KeyboardEvent } from 'react';
2
+ import { useIntl } from 'react-intl';
3
+ import { formatDate } from '@transferwise/formatting';
2
4
 
3
5
  import { MonthFormat } from '../common';
4
6
  import { isWithinRange, moveToWithinRange, returnDateView } from '../common/DateUtils';
7
+ import { isDateDisabled, hasDisabledDatesInMonth } from './_helpers';
8
+ import { LONG_DAY_FORMAT } from './Calendar.constants';
9
+ import { InlinePrompt } from '../Prompt/InlinePrompt';
10
+ import type { DateChangeMeta, DisabledRules } from './Calendar.types';
5
11
  import DayCalendar from './DayCalendar';
6
12
  import { getStartOfDay } from './GetStartOfDay';
13
+ import messages from './Calendar.messages';
7
14
  import MonthCalendar from './MonthCalendar';
8
15
  import YearCalendar from './YearCalendar';
9
16
 
10
17
  export interface CalendarProps {
18
+ /** The currently selected date, or `null` for no selection. */
11
19
  value: Date | null;
20
+ /** Earliest selectable date (inclusive). Dates before this are interactive but not selectable, and show a boundary prompt. */
12
21
  min?: Date | null;
22
+ /** Latest selectable date (inclusive). Dates after this are interactive but not selectable, and show a boundary prompt. */
13
23
  max?: Date | null;
24
+ /** Month name format in the header. Defaults to `'long'` (e.g. "January"). */
14
25
  monthFormat?: `${MonthFormat}`;
26
+ /** Placeholder text shown in the month/year cells when no date is selected. */
15
27
  placeholder?: string;
16
- onChange: (date: Date | null) => void;
17
- onSelect?: (date: Date) => void;
28
+ /** Rules for disabling specific weekdays, individual dates, or date ranges. The required `reason` is shown in an inline prompt and linked to disabled cells via `aria-describedby` for screen reader accessibility. */
29
+ disabledRules?: DisabledRules;
30
+ /** Called whenever the focused date changes — including disabled and out-of-range dates. `meta.isEnabled` is `false` when the date is not selectable. */
31
+ onDateFocus?: (date: Date | null, meta: DateChangeMeta) => void;
32
+ /** Called when a date is confirmed (click or Enter). Only fires for enabled dates. */
33
+ onChange?: (date: Date | null) => void;
18
34
  }
19
35
 
36
+ /**
37
+ * Inline calendar for selecting a single date. Powers [DateLookup](?path=/docs/forms-datelookup--docs).
38
+ */
20
39
  const Calendar = ({
21
40
  value,
22
41
  min: minProp = null,
23
42
  max: maxProp = null,
24
43
  monthFormat = MonthFormat.LONG,
25
44
  placeholder,
45
+ disabledRules,
46
+ onDateFocus,
26
47
  onChange,
27
- onSelect,
28
48
  }: CalendarProps) => {
49
+ const intl = useIntl();
29
50
  const selectedDate = getStartOfDay(value);
30
51
  const min = getStartOfDay(minProp);
31
52
  const max = getStartOfDay(maxProp);
53
+ const disabledReasonId = useId();
54
+ const disabledRulesRef = useRef(disabledRules);
55
+ useEffect(() => {
56
+ disabledRulesRef.current = disabledRules;
57
+ });
58
+
59
+ const [hasInteracted, setHasInteracted] = useState(false);
60
+ const [focusedDate, setFocusedDate] = useState<Date | null>(selectedDate);
61
+
62
+ useEffect(() => {
63
+ // eslint-disable-next-line react-hooks/set-state-in-effect
64
+ setFocusedDate(selectedDate);
65
+ }, [selectedDate?.getTime()]); // eslint-disable-line react-hooks/exhaustive-deps
66
+
67
+ const checkIsEnabled = (date: Date | null) => {
68
+ if (!date) return true;
69
+ return isWithinRange(date, min, max) && !isDateDisabled(date, disabledRulesRef.current);
70
+ };
71
+
72
+ const focusedDateDisabled = focusedDate
73
+ ? !isWithinRange(focusedDate, min, max) || isDateDisabled(focusedDate, disabledRules)
74
+ : false;
32
75
 
33
76
  useEffect(() => {
34
77
  if (selectedDate && !isWithinRange(selectedDate, min, max)) {
35
- onChange(moveToWithinRange(selectedDate, min, max));
78
+ const clamped = moveToWithinRange(selectedDate, min, max);
79
+ setFocusedDate(clamped); // eslint-disable-line react-hooks/set-state-in-effect
80
+ onDateFocus?.(clamped, { isEnabled: checkIsEnabled(clamped) });
36
81
  }
37
- }, [selectedDate?.getTime(), min?.getTime(), max?.getTime(), onChange]); // eslint-disable-line react-hooks/exhaustive-deps
82
+ }, [selectedDate?.getTime(), min?.getTime(), max?.getTime(), onDateFocus]); // eslint-disable-line react-hooks/exhaustive-deps
38
83
 
39
84
  const dateView = returnDateView(selectedDate, min, max);
40
85
  const [viewMonth, setViewMonth] = useState(dateView.getMonth());
@@ -49,11 +94,20 @@ const Calendar = ({
49
94
  setViewYear(view.getFullYear());
50
95
  }, [selectedDate?.getTime(), min?.getTime(), max?.getTime()]); // eslint-disable-line react-hooks/exhaustive-deps
51
96
 
97
+ useEffect(() => {
98
+ if (focusedDate) {
99
+ // eslint-disable-next-line react-hooks/set-state-in-effect
100
+ setViewMonth(focusedDate.getMonth());
101
+ setViewYear(focusedDate.getFullYear());
102
+ }
103
+ }, [focusedDate?.getTime()]); // eslint-disable-line react-hooks/exhaustive-deps
104
+
52
105
  const handleViewDateUpdate = useCallback(
53
106
  // eslint-disable-next-line react-hooks/preserve-manual-memoization
54
107
  ({ month, year }: { month?: number; year?: number }) => {
55
108
  if (month !== undefined) setViewMonth(month);
56
109
  if (year !== undefined) setViewYear(year);
110
+ setHasInteracted(false);
57
111
  },
58
112
  [],
59
113
  );
@@ -64,33 +118,60 @@ const Calendar = ({
64
118
 
65
119
  const handleSelectedDateUpdate = useCallback(
66
120
  (date: Date) => {
67
- if (onSelect) {
68
- onSelect(date);
69
- } else {
121
+ if (!hasInteracted) setHasInteracted(true);
122
+ const isEnabled = checkIsEnabled(date);
123
+ setFocusedDate(date);
124
+ onDateFocus?.(date, { isEnabled });
125
+ if (isEnabled && onChange) {
70
126
  onChange(date);
71
127
  }
72
128
  },
73
- [onChange, onSelect],
129
+ [onDateFocus, onChange], // eslint-disable-line react-hooks/exhaustive-deps
74
130
  );
75
131
 
76
132
  const adjustDate = useCallback(
77
133
  (daysToAdd: number, monthsToAdd: number, yearsToAdd: number) => {
134
+ const base = focusedDate ?? selectedDate;
78
135
  let date: Date | null;
79
- if (selectedDate) {
136
+ if (base) {
80
137
  date = new Date(
81
- mode === 'year' ? selectedDate.getFullYear() + yearsToAdd : selectedDate.getFullYear(),
82
- mode === 'month' ? selectedDate.getMonth() + monthsToAdd : selectedDate.getMonth(),
83
- mode === 'day' ? selectedDate.getDate() + daysToAdd : selectedDate.getDate(),
138
+ mode === 'year' ? base.getFullYear() + yearsToAdd : base.getFullYear(),
139
+ mode === 'month' ? base.getMonth() + monthsToAdd : base.getMonth(),
140
+ mode === 'day' ? base.getDate() + daysToAdd : base.getDate(),
84
141
  );
85
142
  } else {
86
143
  date = getStartOfDay(new Date());
87
144
  }
88
145
  date &&= moveToWithinRange(date, min, max);
89
- if (date?.getTime() !== selectedDate?.getTime()) {
90
- onChange(date);
146
+ if (date?.getTime() !== focusedDate?.getTime()) {
147
+ const monthChanged = date
148
+ ? date.getMonth() !== focusedDate?.getMonth() ||
149
+ date.getFullYear() !== focusedDate?.getFullYear()
150
+ : false;
151
+ setFocusedDate(date);
152
+ if (date) {
153
+ setViewMonth(date.getMonth());
154
+ setViewYear(date.getFullYear());
155
+ }
156
+ if (monthChanged) {
157
+ setHasInteracted(false);
158
+ } else if (!hasInteracted) {
159
+ setHasInteracted(true);
160
+ }
161
+ onDateFocus?.(date, { isEnabled: checkIsEnabled(date) });
91
162
  }
92
163
  },
93
- [selectedDate, min, max, mode, onChange],
164
+ [focusedDate, selectedDate, min, max, mode, onDateFocus], // eslint-disable-line react-hooks/exhaustive-deps
165
+ );
166
+
167
+ const handleDayFocus = useCallback(
168
+ (date: Date) => {
169
+ if (date.getTime() !== focusedDate?.getTime()) {
170
+ setFocusedDate(date);
171
+ onDateFocus?.(date, { isEnabled: checkIsEnabled(date) });
172
+ }
173
+ },
174
+ [focusedDate, onDateFocus], // eslint-disable-line react-hooks/exhaustive-deps
94
175
  );
95
176
 
96
177
  const handleKeyDown = useCallback(
@@ -112,25 +193,73 @@ const Calendar = ({
112
193
  adjustDate(7, 4, 4);
113
194
  event.preventDefault();
114
195
  break;
196
+ case 'Enter':
197
+ case ' ':
198
+ if (
199
+ mode === 'day' &&
200
+ focusedDate &&
201
+ (event.target as HTMLElement).hasAttribute('data-calendar-cell')
202
+ ) {
203
+ handleSelectedDateUpdate(focusedDate);
204
+ event.preventDefault();
205
+ }
206
+ break;
115
207
  default:
116
208
  break;
117
209
  }
118
210
  },
119
- [adjustDate],
211
+ [adjustDate, focusedDate, mode, handleSelectedDateUpdate],
120
212
  );
121
213
 
214
+ const focusedDateOutOfRange = focusedDate ? !isWithinRange(focusedDate, min, max) : false;
215
+
216
+ const formatDateForPrompt = (date: Date) => formatDate(date, intl.locale, LONG_DAY_FORMAT);
217
+
218
+ const outOfRangePromptText =
219
+ min && max
220
+ ? intl.formatMessage(messages.outOfRangeBetween, {
221
+ min: formatDateForPrompt(min),
222
+ max: formatDateForPrompt(max),
223
+ })
224
+ : min
225
+ ? intl.formatMessage(messages.outOfRangeAfter, { min: formatDateForPrompt(min) })
226
+ : max
227
+ ? intl.formatMessage(messages.outOfRangeBefore, { max: formatDateForPrompt(max) })
228
+ : '';
229
+
230
+ const hasOutOfRangeDates = mode === 'day' && !!(min || max);
231
+ const showRulesPrompt =
232
+ mode === 'day' &&
233
+ !!disabledRules &&
234
+ hasDisabledDatesInMonth(viewMonth, viewYear, disabledRules, min, max);
235
+ const showOutOfRangePrompt = mode === 'day' && focusedDateOutOfRange;
236
+ const showPrompt = hasOutOfRangeDates || showRulesPrompt;
237
+
238
+ const promptText = showOutOfRangePrompt
239
+ ? outOfRangePromptText
240
+ : (disabledRules?.reason ?? outOfRangePromptText);
241
+ const promptSentiment = showOutOfRangePrompt || focusedDateDisabled ? 'negative' : 'neutral';
242
+ // Visible when there's an actionable message; sr-only when only hasOutOfRangeDates is true
243
+ // (prompt exists for aria-describedby but no visual cue needed until an out-of-range date is focused)
244
+ const promptClassName = showOutOfRangePrompt || showRulesPrompt ? 'm-t-1' : 'sr-only';
245
+
122
246
  return (
123
247
  // eslint-disable-next-line jsx-a11y/no-static-element-interactions
124
248
  <div onKeyDown={handleKeyDown}>
125
249
  {mode === 'day' && (
126
250
  <DayCalendar
127
251
  selectedDate={selectedDate}
252
+ focusedDate={focusedDate}
128
253
  min={min}
129
254
  max={max}
130
255
  viewMonth={viewMonth}
131
256
  viewYear={viewYear}
132
257
  monthFormat={monthFormat}
258
+ disabledRules={disabledRules}
259
+ disabledReasonId={showPrompt ? disabledReasonId : undefined}
260
+ announceReasonOnFocus={!hasInteracted}
133
261
  onSelect={handleSelectedDateUpdate}
262
+ onDayFocus={handleDayFocus}
134
263
  onLabelClick={switchToYears}
135
264
  onViewDateUpdate={handleViewDateUpdate}
136
265
  />
@@ -138,6 +267,7 @@ const Calendar = ({
138
267
  {mode === 'month' && (
139
268
  <MonthCalendar
140
269
  selectedDate={selectedDate}
270
+ focusedDate={focusedDate}
141
271
  min={min}
142
272
  max={max}
143
273
  viewYear={viewYear}
@@ -150,6 +280,7 @@ const Calendar = ({
150
280
  {mode === 'year' && (
151
281
  <YearCalendar
152
282
  selectedDate={selectedDate}
283
+ focusedDate={focusedDate}
153
284
  min={min}
154
285
  max={max}
155
286
  viewYear={viewYear}
@@ -158,6 +289,16 @@ const Calendar = ({
158
289
  onViewDateUpdate={handleViewDateUpdate}
159
290
  />
160
291
  )}
292
+ {showPrompt && (
293
+ <InlinePrompt
294
+ sentiment={promptSentiment}
295
+ width="full"
296
+ id={disabledReasonId}
297
+ className={promptClassName}
298
+ >
299
+ {promptText}
300
+ </InlinePrompt>
301
+ )}
161
302
  </div>
162
303
  );
163
304
  };
@@ -0,0 +1,35 @@
1
+ export interface DisabledDateRange {
2
+ start: Date;
3
+ end: Date;
4
+ }
5
+
6
+ export type WeekDayType = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun';
7
+
8
+ export interface DisabledRules {
9
+ /** Days of the week to disable (e.g. `['sat', 'sun']` for weekends). */
10
+ weekdays?: WeekDayType[];
11
+ /** Specific calendar dates to disable. */
12
+ dates?: Date[];
13
+ /** Inclusive date ranges to disable. */
14
+ ranges?: DisabledDateRange[];
15
+ /**
16
+ * Human-readable explanation shown in an InlinePrompt below the calendar grid when
17
+ * disabled dates exist in the current month. Required for accessibility: it is linked
18
+ * to disabled cells via `aria-describedby` so screen reader users understand why a
19
+ * date is unavailable. When an out-of-range date is focused, the built-in boundary
20
+ * message takes precedence over this text.
21
+ */
22
+ reason: string;
23
+ }
24
+
25
+ export interface DateChangeMeta {
26
+ /** Whether the focused date is selectable (not disabled or out of range). */
27
+ isEnabled: boolean;
28
+ }
29
+
30
+ export interface OnDateSelectParams {
31
+ /** The date that was focused or clicked. */
32
+ date: Date;
33
+ /** Whether the date is selectable (not disabled or out of range). */
34
+ isEnabled: boolean;
35
+ }
@@ -15,6 +15,8 @@ interface DateHeaderProps {
15
15
  onPreviousClick: () => void;
16
16
  onNextClick: () => void;
17
17
  dateMode: string;
18
+ isPreviousDisabled?: boolean;
19
+ isNextDisabled?: boolean;
18
20
  }
19
21
 
20
22
  const DateHeader: React.FC<DateHeaderProps> = ({
@@ -23,6 +25,8 @@ const DateHeader: React.FC<DateHeaderProps> = ({
23
25
  onPreviousClick,
24
26
  onNextClick,
25
27
  dateMode,
28
+ isPreviousDisabled,
29
+ isNextDisabled,
26
30
  }) => {
27
31
  const intl = useIntl();
28
32
  const { isMobile } = useLayout();
@@ -34,6 +38,7 @@ const DateHeader: React.FC<DateHeaderProps> = ({
34
38
  priority="minimal"
35
39
  className="d-inline-flex"
36
40
  aria-label={`${intl.formatMessage(messages.previous)} ${dateMode}`}
41
+ disabled={isPreviousDisabled}
37
42
  onClick={onPreviousClick}
38
43
  >
39
44
  <ChevronLeft className="left-single-direction" />
@@ -61,6 +66,7 @@ const DateHeader: React.FC<DateHeaderProps> = ({
61
66
  className="d-inline-flex"
62
67
  aria-label={`${intl.formatMessage(messages.next)} ${dateMode}`}
63
68
  priority="minimal"
69
+ disabled={isNextDisabled}
64
70
  onClick={onNextClick}
65
71
  >
66
72
  <ChevronRight className="right-single-direction" />
@@ -2,6 +2,7 @@ import { formatDate } from '@transferwise/formatting';
2
2
  import { PureComponent } from 'react';
3
3
 
4
4
  import { MonthFormat } from '../../common';
5
+ import type { DisabledRules } from '../Calendar.types';
5
6
  import messages from '../Calendar.messages';
6
7
  import DateHeader from '../DateHeader';
7
8
 
@@ -10,12 +11,17 @@ import { injectIntl, type WrappedComponentProps } from '../../common/injectIntl'
10
11
 
11
12
  interface DayCalendarProps extends WrappedComponentProps {
12
13
  selectedDate: Date | null;
14
+ focusedDate?: Date | null;
13
15
  min: Date | null;
14
16
  max: Date | null;
15
17
  viewMonth: number;
16
18
  viewYear: number;
17
19
  monthFormat: `${MonthFormat}`;
20
+ disabledRules?: DisabledRules;
21
+ disabledReasonId?: string;
22
+ announceReasonOnFocus?: boolean;
18
23
  onSelect: (date: Date) => void;
24
+ onDayFocus?: (date: Date) => void;
19
25
  onLabelClick: () => void;
20
26
  onViewDateUpdate: (date: { month: number; year: number }) => void;
21
27
  }
@@ -40,15 +46,31 @@ class DayCalendar extends PureComponent<DayCalendarProps> {
40
46
  render() {
41
47
  const {
42
48
  selectedDate,
49
+ focusedDate,
43
50
  min,
44
51
  max,
45
52
  viewMonth,
46
53
  viewYear,
47
54
  intl: { locale, formatMessage },
48
55
  monthFormat,
56
+ disabledRules,
57
+ disabledReasonId,
58
+ announceReasonOnFocus,
49
59
  onLabelClick,
50
60
  onSelect,
61
+ onDayFocus,
51
62
  } = this.props;
63
+ const prevMonth = viewMonth <= 0 ? 11 : viewMonth - 1;
64
+ const prevYear = viewMonth <= 0 ? viewYear - 1 : viewYear;
65
+ const nextMonth = viewMonth >= 11 ? 0 : viewMonth + 1;
66
+ const nextYear = viewMonth >= 11 ? viewYear + 1 : viewYear;
67
+ const isPreviousDisabled = !!(
68
+ min && new Date(prevYear, prevMonth) < new Date(min.getFullYear(), min.getMonth())
69
+ );
70
+ const isNextDisabled = !!(
71
+ max && new Date(nextYear, nextMonth) > new Date(max.getFullYear(), max.getMonth())
72
+ );
73
+
52
74
  return (
53
75
  <>
54
76
  <DateHeader
@@ -57,11 +79,27 @@ class DayCalendar extends PureComponent<DayCalendarProps> {
57
79
  year: 'numeric',
58
80
  })}
59
81
  dateMode={formatMessage(messages.month)}
82
+ isPreviousDisabled={isPreviousDisabled}
83
+ isNextDisabled={isNextDisabled}
60
84
  onLabelClick={onLabelClick}
61
85
  onPreviousClick={this.selectPreviousMonth}
62
86
  onNextClick={this.selectNextMonth}
63
87
  />
64
- <DayCalendarTable {...{ selectedDate, min, max, viewMonth, viewYear, onSelect }} />
88
+ <DayCalendarTable
89
+ {...{
90
+ selectedDate,
91
+ focusedDate,
92
+ min,
93
+ max,
94
+ viewMonth,
95
+ viewYear,
96
+ disabledRules,
97
+ disabledReasonId,
98
+ announceReasonOnFocus,
99
+ onSelect,
100
+ onDayFocus,
101
+ }}
102
+ />
65
103
  </>
66
104
  );
67
105
  }
@@ -3,19 +3,27 @@ import { PureComponent } from 'react';
3
3
  import { injectIntl, type WrappedComponentProps } from '../../../common/injectIntl';
4
4
 
5
5
  import { getDayNames, isWithinRange } from '../../../common/DateUtils';
6
+ import { isDateDisabled } from '../../_helpers';
7
+ import { LONG_DAY_FORMAT } from '../../Calendar.constants';
8
+ import type { DisabledRules } from '../../Calendar.types';
6
9
  import { getFocusableTime } from '../../GetFocusableTime/GetFocusableTime';
7
10
  import { getStartOfDay } from '../../GetStartOfDay';
8
- import TableLink from '../../tableLink';
11
+ import TableLink from '../../TableLink';
9
12
 
10
13
  const SHORT_DAY_FORMAT: Intl.DateTimeFormatOptions = { day: 'numeric' };
11
14
 
12
15
  interface DayCalendarTableProps extends WrappedComponentProps {
13
16
  selectedDate: Date | null;
17
+ focusedDate?: Date | null;
14
18
  min: Date | null;
15
19
  max: Date | null;
16
20
  viewMonth: number;
17
21
  viewYear: number;
22
+ disabledRules?: DisabledRules;
23
+ disabledReasonId?: string;
24
+ announceReasonOnFocus?: boolean;
18
25
  onSelect: (date: Date) => void;
26
+ onDayFocus?: (date: Date) => void;
19
27
  }
20
28
 
21
29
  class DayCalendarTable extends PureComponent<DayCalendarTableProps> {
@@ -63,21 +71,41 @@ class DayCalendarTable extends PureComponent<DayCalendarTableProps> {
63
71
  onSelect(new Date(viewYear, viewMonth, day));
64
72
  };
65
73
 
66
- isDisabled = (day: number) => {
74
+ focusDay = (day: number) => {
75
+ const { viewMonth, viewYear, onDayFocus } = this.props;
76
+ onDayFocus?.(new Date(viewYear, viewMonth, day));
77
+ };
78
+
79
+ isOutOfRange = (day: number) => {
67
80
  if (day < 0) {
68
81
  return true;
69
82
  }
70
83
  const { min, max, viewMonth, viewYear } = this.props;
71
84
  const date = new Date(viewYear, viewMonth, day);
72
-
73
85
  return !isWithinRange(date, min, max);
74
86
  };
75
87
 
88
+ isDisabled = (day: number) => {
89
+ if (day < 0) {
90
+ return false;
91
+ }
92
+ const { viewMonth, viewYear, disabledRules } = this.props;
93
+ const date = new Date(viewYear, viewMonth, day);
94
+ return isDateDisabled(date, disabledRules);
95
+ };
96
+
97
+ isUnavailable = (day: number) => this.isOutOfRange(day) || this.isDisabled(day);
98
+
76
99
  isActive = (day: number) => {
77
100
  const { selectedDate, viewMonth, viewYear } = this.props;
78
101
  return !!(selectedDate && Number(new Date(viewYear, viewMonth, day)) === Number(selectedDate));
79
102
  };
80
103
 
104
+ isFocused = (day: number) => {
105
+ const { focusedDate, viewMonth, viewYear } = this.props;
106
+ return !!(focusedDate && Number(new Date(viewYear, viewMonth, day)) === Number(focusedDate));
107
+ };
108
+
81
109
  isToday = (day: number) => {
82
110
  const { viewMonth, viewYear } = this.props;
83
111
  return Number(getStartOfDay(new Date())) === Number(new Date(viewYear, viewMonth, day));
@@ -86,15 +114,15 @@ class DayCalendarTable extends PureComponent<DayCalendarTableProps> {
86
114
  getAutofocusDay = (weeks: number[][]) => {
87
115
  const days = weeks.flatMap((week) => week);
88
116
  return getFocusableTime({
89
- isActive: this.isActive,
117
+ isActive: this.isFocused,
90
118
  isNow: this.isToday,
91
- isDisabled: this.isDisabled,
119
+ isDisabled: this.isUnavailable,
92
120
  timeSpan: days,
93
121
  });
94
122
  };
95
123
 
96
124
  render() {
97
- const { viewMonth, viewYear, intl } = this.props;
125
+ const { viewMonth, viewYear, intl, disabledReasonId, announceReasonOnFocus } = this.props;
98
126
  const weeks = this.getTableStructure();
99
127
  const autoFocusDay = this.getAutofocusDay(weeks);
100
128
  return (
@@ -124,27 +152,39 @@ class DayCalendarTable extends PureComponent<DayCalendarTableProps> {
124
152
  {/* eslint-disable react/no-array-index-key */}
125
153
  {weeks.map((week, weekIndex) => (
126
154
  <tr key={weekIndex}>
127
- {week.map((day, dayIndex) => (
128
- <td key={dayIndex} className={dayIndex > 4 ? 'weekend' : ''}>
129
- {day !== -1 && (
130
- <TableLink
131
- item={day}
132
- type="day"
133
- title={formatDate(
134
- new Date(viewYear, viewMonth, day),
135
- intl.locale,
136
- SHORT_DAY_FORMAT,
137
- )}
138
- autofocus={day === autoFocusDay}
139
- longTitle={formatDate(new Date(viewYear, viewMonth, day), intl.locale)}
140
- active={this.isActive(day)}
141
- disabled={this.isDisabled(day)}
142
- today={this.isToday(day)}
143
- onClick={this.selectDay}
144
- />
145
- )}
146
- </td>
147
- ))}
155
+ {week.map((day, dayIndex) => {
156
+ const dayOutOfRange = this.isOutOfRange(day);
157
+ const dayDisabled = this.isDisabled(day);
158
+ return (
159
+ <td key={dayIndex} className={dayIndex > 4 ? 'weekend' : ''}>
160
+ {day !== -1 && (
161
+ <TableLink
162
+ item={day}
163
+ type="day"
164
+ title={formatDate(
165
+ new Date(viewYear, viewMonth, day),
166
+ intl.locale,
167
+ SHORT_DAY_FORMAT,
168
+ )}
169
+ autofocus={day === autoFocusDay}
170
+ longTitle={formatDate(
171
+ new Date(viewYear, viewMonth, day),
172
+ intl.locale,
173
+ LONG_DAY_FORMAT,
174
+ )}
175
+ active={this.isActive(day)}
176
+ outOfRange={dayOutOfRange}
177
+ disabled={dayDisabled}
178
+ today={this.isToday(day)}
179
+ disabledReasonId={disabledReasonId}
180
+ announceReasonOnFocus={announceReasonOnFocus}
181
+ onClick={this.selectDay}
182
+ onFocus={this.focusDay}
183
+ />
184
+ )}
185
+ </td>
186
+ );
187
+ })}
148
188
  </tr>
149
189
  ))}
150
190
  {/* eslint-enable react/no-array-index-key */}
@@ -9,6 +9,7 @@ import MonthCalendarTable from './Table';
9
9
 
10
10
  interface MonthCalendarProps extends WrappedComponentProps {
11
11
  selectedDate: Date | null;
12
+ focusedDate?: Date | null;
12
13
  min: Date | null;
13
14
  max: Date | null;
14
15
  viewYear: number;
@@ -35,6 +36,7 @@ class MonthCalendar extends PureComponent<MonthCalendarProps> {
35
36
  render() {
36
37
  const {
37
38
  selectedDate,
39
+ focusedDate,
38
40
  min,
39
41
  max,
40
42
  viewYear,
@@ -42,17 +44,23 @@ class MonthCalendar extends PureComponent<MonthCalendarProps> {
42
44
  placeholder,
43
45
  onLabelClick,
44
46
  } = this.props;
47
+ const isPreviousDisabled = !!(min && viewYear - 1 < min.getFullYear());
48
+ const isNextDisabled = !!(max && viewYear + 1 > max.getFullYear());
49
+
45
50
  return (
46
51
  <>
47
52
  <DateHeader
48
53
  dateMode={formatMessage(messages.year)}
49
54
  label={formatDate(new Date(viewYear, 0), locale, { year: 'numeric' })}
55
+ isPreviousDisabled={isPreviousDisabled}
56
+ isNextDisabled={isNextDisabled}
50
57
  onLabelClick={onLabelClick}
51
58
  onPreviousClick={this.selectPreviousYear}
52
59
  onNextClick={this.selectNextYear}
53
60
  />
54
61
  <MonthCalendarTable
55
62
  selectedDate={selectedDate}
63
+ focusedDate={focusedDate}
56
64
  min={min}
57
65
  max={max}
58
66
  viewYear={viewYear}