@transferwise/components 46.156.0 → 46.157.0

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 (215) 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 +121 -17
  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 +122 -18
  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 +95 -0
  34. package/build/Calendar/TableLink/TableLink.js.map +1 -0
  35. package/build/Calendar/TableLink/TableLink.mjs +91 -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/clampToDayViewBoundary/clampToDayViewBoundary.js +24 -0
  46. package/build/Calendar/_helpers/clampToDayViewBoundary/clampToDayViewBoundary.js.map +1 -0
  47. package/build/Calendar/_helpers/clampToDayViewBoundary/clampToDayViewBoundary.mjs +22 -0
  48. package/build/Calendar/_helpers/clampToDayViewBoundary/clampToDayViewBoundary.mjs.map +1 -0
  49. package/build/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.js +33 -0
  50. package/build/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.js.map +1 -0
  51. package/build/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.mjs +31 -0
  52. package/build/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.mjs.map +1 -0
  53. package/build/Calendar/_helpers/isDateDisabled/isDateDisabled.js +40 -0
  54. package/build/Calendar/_helpers/isDateDisabled/isDateDisabled.js.map +1 -0
  55. package/build/Calendar/_helpers/isDateDisabled/isDateDisabled.mjs +38 -0
  56. package/build/Calendar/_helpers/isDateDisabled/isDateDisabled.mjs.map +1 -0
  57. package/build/DateLookup/DateLookup.js +20 -9
  58. package/build/DateLookup/DateLookup.js.map +1 -1
  59. package/build/DateLookup/DateLookup.mjs +20 -9
  60. package/build/DateLookup/DateLookup.mjs.map +1 -1
  61. package/build/ExpressiveMoneyInput/CurrencySelector/CurrencySelector.js +3 -5
  62. package/build/ExpressiveMoneyInput/CurrencySelector/CurrencySelector.js.map +1 -1
  63. package/build/ExpressiveMoneyInput/CurrencySelector/CurrencySelector.mjs +3 -5
  64. package/build/ExpressiveMoneyInput/CurrencySelector/CurrencySelector.mjs.map +1 -1
  65. package/build/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.js.map +1 -1
  66. package/build/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.mjs.map +1 -1
  67. package/build/Typeahead/Typeahead.js +53 -27
  68. package/build/Typeahead/Typeahead.js.map +1 -1
  69. package/build/Typeahead/Typeahead.messages.js +3 -0
  70. package/build/Typeahead/Typeahead.messages.js.map +1 -1
  71. package/build/Typeahead/Typeahead.messages.mjs +3 -0
  72. package/build/Typeahead/Typeahead.messages.mjs.map +1 -1
  73. package/build/Typeahead/Typeahead.mjs +53 -27
  74. package/build/Typeahead/Typeahead.mjs.map +1 -1
  75. package/build/Typeahead/TypeaheadInput/TypeaheadInput.js +2 -2
  76. package/build/Typeahead/TypeaheadInput/TypeaheadInput.js.map +1 -1
  77. package/build/Typeahead/TypeaheadInput/TypeaheadInput.mjs +2 -2
  78. package/build/Typeahead/TypeaheadInput/TypeaheadInput.mjs.map +1 -1
  79. package/build/Typeahead/TypeaheadOption/TypeaheadOption.js +3 -1
  80. package/build/Typeahead/TypeaheadOption/TypeaheadOption.js.map +1 -1
  81. package/build/Typeahead/TypeaheadOption/TypeaheadOption.mjs +3 -1
  82. package/build/Typeahead/TypeaheadOption/TypeaheadOption.mjs.map +1 -1
  83. package/build/i18n/en.json +5 -0
  84. package/build/i18n/en.json.js +5 -0
  85. package/build/i18n/en.json.js.map +1 -1
  86. package/build/i18n/en.json.mjs +5 -0
  87. package/build/i18n/en.json.mjs.map +1 -1
  88. package/build/index.js +2 -2
  89. package/build/index.mjs +1 -1
  90. package/build/main.css +133 -43
  91. package/build/styles/Calendar/Calendar.css +112 -37
  92. package/build/styles/Calendar/TableLink/TableLink.css +115 -0
  93. package/build/styles/Typeahead/Typeahead.css +4 -0
  94. package/build/styles/Typeahead/TypeaheadOption/TypeaheadOption.css +5 -0
  95. package/build/styles/main.css +133 -43
  96. package/build/types/Body/Body.d.ts +2 -2
  97. package/build/types/Calendar/Calendar.constants.d.ts +2 -0
  98. package/build/types/Calendar/Calendar.constants.d.ts.map +1 -0
  99. package/build/types/Calendar/Calendar.d.ts +16 -3
  100. package/build/types/Calendar/Calendar.d.ts.map +1 -1
  101. package/build/types/Calendar/Calendar.messages.d.ts +20 -0
  102. package/build/types/Calendar/Calendar.messages.d.ts.map +1 -1
  103. package/build/types/Calendar/Calendar.types.d.ts +32 -0
  104. package/build/types/Calendar/Calendar.types.d.ts.map +1 -0
  105. package/build/types/Calendar/DateHeader/DateHeader.d.ts +2 -0
  106. package/build/types/Calendar/DateHeader/DateHeader.d.ts.map +1 -1
  107. package/build/types/Calendar/DayCalendar/DayCalendar.d.ts +7 -1
  108. package/build/types/Calendar/DayCalendar/DayCalendar.d.ts.map +1 -1
  109. package/build/types/Calendar/DayCalendar/Table/DayCalendarTable.d.ts +6 -0
  110. package/build/types/Calendar/DayCalendar/Table/DayCalendarTable.d.ts.map +1 -1
  111. package/build/types/Calendar/MonthCalendar/MonthCalendar.d.ts +1 -0
  112. package/build/types/Calendar/MonthCalendar/MonthCalendar.d.ts.map +1 -1
  113. package/build/types/Calendar/MonthCalendar/Table/MonthCalendarTable.d.ts +2 -1
  114. package/build/types/Calendar/MonthCalendar/Table/MonthCalendarTable.d.ts.map +1 -1
  115. package/build/types/Calendar/{tableLink → TableLink}/TableLink.d.ts +5 -1
  116. package/build/types/Calendar/TableLink/TableLink.d.ts.map +1 -0
  117. package/build/types/Calendar/TableLink/index.d.ts.map +1 -0
  118. package/build/types/Calendar/YearCalendar/Table/YearCalendarTable.d.ts +2 -1
  119. package/build/types/Calendar/YearCalendar/Table/YearCalendarTable.d.ts.map +1 -1
  120. package/build/types/Calendar/YearCalendar/YearCalendar.d.ts +1 -0
  121. package/build/types/Calendar/YearCalendar/YearCalendar.d.ts.map +1 -1
  122. package/build/types/Calendar/_helpers/clampToDayViewBoundary/clampToDayViewBoundary.d.ts +8 -0
  123. package/build/types/Calendar/_helpers/clampToDayViewBoundary/clampToDayViewBoundary.d.ts.map +1 -0
  124. package/build/types/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.d.ts +3 -0
  125. package/build/types/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.d.ts.map +1 -0
  126. package/build/types/Calendar/_helpers/index.d.ts +4 -0
  127. package/build/types/Calendar/_helpers/index.d.ts.map +1 -0
  128. package/build/types/Calendar/_helpers/isDateDisabled/isDateDisabled.d.ts +3 -0
  129. package/build/types/Calendar/_helpers/isDateDisabled/isDateDisabled.d.ts.map +1 -0
  130. package/build/types/Calendar/index.d.ts +1 -0
  131. package/build/types/Calendar/index.d.ts.map +1 -1
  132. package/build/types/DateLookup/DateLookup.d.ts +24 -5
  133. package/build/types/DateLookup/DateLookup.d.ts.map +1 -1
  134. package/build/types/DateLookup/index.d.ts +1 -0
  135. package/build/types/DateLookup/index.d.ts.map +1 -1
  136. package/build/types/ExpressiveMoneyInput/CurrencySelector/CurrencySelector.d.ts +6 -4
  137. package/build/types/ExpressiveMoneyInput/CurrencySelector/CurrencySelector.d.ts.map +1 -1
  138. package/build/types/IconButton/IconButton.d.ts +1 -1
  139. package/build/types/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.d.ts +1 -1
  140. package/build/types/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.d.ts.map +1 -1
  141. package/build/types/MoneyInput/MoneyInput.d.ts +1 -1
  142. package/build/types/MoneyInput/MoneyInput.d.ts.map +1 -1
  143. package/build/types/Title/Title.d.ts +2 -2
  144. package/build/types/Typeahead/Typeahead.d.ts.map +1 -1
  145. package/build/types/Typeahead/Typeahead.messages.d.ts +5 -0
  146. package/build/types/Typeahead/Typeahead.messages.d.ts.map +1 -1
  147. package/build/types/Typeahead/TypeaheadInput/TypeaheadInput.d.ts.map +1 -1
  148. package/build/types/Typeahead/TypeaheadOption/TypeaheadOption.d.ts +1 -0
  149. package/build/types/Typeahead/TypeaheadOption/TypeaheadOption.d.ts.map +1 -1
  150. package/build/types/Upload/Steps/UploadImageStep/UploadImageStep.d.ts +1 -1
  151. package/build/types/UploadInput/UploadItem/UploadItemLink.d.ts +1 -1
  152. package/build/types/index.d.ts +3 -2
  153. package/build/types/index.d.ts.map +1 -1
  154. package/build/types/test-utils/index.d.ts +10 -0
  155. package/build/types/test-utils/index.d.ts.map +1 -1
  156. package/package.json +1 -1
  157. package/src/Calendar/Calendar.constants.ts +5 -0
  158. package/src/Calendar/Calendar.css +112 -37
  159. package/src/Calendar/Calendar.disabledDates.test.tsx +610 -0
  160. package/src/Calendar/Calendar.less +5 -35
  161. package/src/Calendar/Calendar.messages.ts +21 -0
  162. package/src/Calendar/Calendar.test.tsx +342 -32
  163. package/src/Calendar/Calendar.tsx +169 -19
  164. package/src/Calendar/Calendar.types.ts +35 -0
  165. package/src/Calendar/DateHeader/DateHeader.tsx +6 -0
  166. package/src/Calendar/DayCalendar/DayCalendar.tsx +39 -1
  167. package/src/Calendar/DayCalendar/Table/DayCalendarTable.tsx +67 -27
  168. package/src/Calendar/MonthCalendar/MonthCalendar.tsx +8 -0
  169. package/src/Calendar/MonthCalendar/Table/MonthCalendarTable.tsx +15 -3
  170. package/src/Calendar/TableLink/TableLink.css +115 -0
  171. package/src/Calendar/TableLink/TableLink.less +68 -0
  172. package/src/Calendar/TableLink/TableLink.tsx +117 -0
  173. package/src/Calendar/YearCalendar/Table/YearCalendarTable.tsx +15 -3
  174. package/src/Calendar/YearCalendar/YearCalendar.tsx +9 -0
  175. package/src/Calendar/_helpers/clampToDayViewBoundary/clampToDayViewBoundary.test.ts +90 -0
  176. package/src/Calendar/_helpers/clampToDayViewBoundary/clampToDayViewBoundary.ts +25 -0
  177. package/src/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.test.ts +98 -0
  178. package/src/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.ts +45 -0
  179. package/src/Calendar/_helpers/index.ts +3 -0
  180. package/src/Calendar/_helpers/isDateDisabled/isDateDisabled.test.ts +169 -0
  181. package/src/Calendar/_helpers/isDateDisabled/isDateDisabled.ts +59 -0
  182. package/src/Calendar/_stories/Calendar.accessibility.docs.mdx +118 -0
  183. package/src/Calendar/_stories/Calendar.story.tsx +144 -4
  184. package/src/Calendar/_stories/Calendar.test.story.tsx +114 -0
  185. package/src/Calendar/index.ts +7 -0
  186. package/src/DateLookup/DateLookup.disabledDates.test.tsx +312 -0
  187. package/src/DateLookup/DateLookup.test.tsx +34 -37
  188. package/src/DateLookup/DateLookup.tsx +37 -13
  189. package/src/DateLookup/_stories/DateLookup.accessibility.docs.mdx +108 -0
  190. package/src/DateLookup/_stories/DateLookup.story.tsx +126 -2
  191. package/src/DateLookup/index.ts +6 -0
  192. package/src/ExpressiveMoneyInput/CurrencySelector/CurrencySelector.tsx +12 -13
  193. package/src/Inputs/SelectInput/SelectInput.test.tsx +37 -0
  194. package/src/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.tsx +1 -1
  195. package/src/Typeahead/Typeahead.css +4 -0
  196. package/src/Typeahead/Typeahead.less +2 -0
  197. package/src/Typeahead/Typeahead.messages.ts +5 -0
  198. package/src/Typeahead/Typeahead.test.tsx +80 -3
  199. package/src/Typeahead/Typeahead.tsx +50 -27
  200. package/src/Typeahead/TypeaheadInput/TypeaheadInput.tsx +2 -6
  201. package/src/Typeahead/TypeaheadOption/TypeaheadOption.css +5 -0
  202. package/src/Typeahead/TypeaheadOption/TypeaheadOption.less +9 -0
  203. package/src/Typeahead/TypeaheadOption/TypeaheadOption.tsx +4 -2
  204. package/src/i18n/en.json +4 -0
  205. package/src/index.ts +3 -2
  206. package/src/main.css +133 -43
  207. package/build/Calendar/tableLink/TableLink.js +0 -61
  208. package/build/Calendar/tableLink/TableLink.js.map +0 -1
  209. package/build/Calendar/tableLink/TableLink.mjs +0 -57
  210. package/build/Calendar/tableLink/TableLink.mjs.map +0 -1
  211. package/build/types/Calendar/tableLink/TableLink.d.ts.map +0 -1
  212. package/build/types/Calendar/tableLink/index.d.ts.map +0 -1
  213. package/src/Calendar/tableLink/TableLink.tsx +0 -77
  214. /package/build/types/Calendar/{tableLink → TableLink}/index.d.ts +0 -0
  215. /package/src/Calendar/{tableLink → TableLink}/index.ts +0 -0
@@ -1,40 +1,91 @@
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 { clampToDayViewBoundary, 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
+ * Temporary toggle — set `true` to restore strict min/max keyboard clamping.
38
+ * @see https://transferwise.atlassian.net/browse/DS-8518
39
+ */
40
+ const STRICT_KEYBOARD_CLAMPING = false;
41
+
42
+ /**
43
+ * Inline calendar for selecting a single date. Powers [DateLookup](?path=/docs/forms-datelookup--docs).
44
+ */
20
45
  const Calendar = ({
21
46
  value,
22
47
  min: minProp = null,
23
48
  max: maxProp = null,
24
49
  monthFormat = MonthFormat.LONG,
25
50
  placeholder,
51
+ disabledRules,
52
+ onDateFocus,
26
53
  onChange,
27
- onSelect,
28
54
  }: CalendarProps) => {
55
+ const intl = useIntl();
29
56
  const selectedDate = getStartOfDay(value);
30
57
  const min = getStartOfDay(minProp);
31
58
  const max = getStartOfDay(maxProp);
59
+ const disabledReasonId = useId();
60
+ const disabledRulesRef = useRef(disabledRules);
61
+ useEffect(() => {
62
+ disabledRulesRef.current = disabledRules;
63
+ });
64
+
65
+ const [hasInteracted, setHasInteracted] = useState(false);
66
+ const [focusedDate, setFocusedDate] = useState<Date | null>(selectedDate);
67
+
68
+ useEffect(() => {
69
+ // eslint-disable-next-line react-hooks/set-state-in-effect
70
+ setFocusedDate(selectedDate);
71
+ }, [selectedDate?.getTime()]); // eslint-disable-line react-hooks/exhaustive-deps
72
+
73
+ const checkIsEnabled = (date: Date | null) => {
74
+ if (!date) return true;
75
+ return isWithinRange(date, min, max) && !isDateDisabled(date, disabledRulesRef.current);
76
+ };
77
+
78
+ const focusedDateDisabled = focusedDate
79
+ ? !isWithinRange(focusedDate, min, max) || isDateDisabled(focusedDate, disabledRules)
80
+ : false;
32
81
 
33
82
  useEffect(() => {
34
83
  if (selectedDate && !isWithinRange(selectedDate, min, max)) {
35
- onChange(moveToWithinRange(selectedDate, min, max));
84
+ const clamped = moveToWithinRange(selectedDate, min, max);
85
+ setFocusedDate(clamped); // eslint-disable-line react-hooks/set-state-in-effect
86
+ onDateFocus?.(clamped, { isEnabled: checkIsEnabled(clamped) });
36
87
  }
37
- }, [selectedDate?.getTime(), min?.getTime(), max?.getTime(), onChange]); // eslint-disable-line react-hooks/exhaustive-deps
88
+ }, [selectedDate?.getTime(), min?.getTime(), max?.getTime(), onDateFocus]); // eslint-disable-line react-hooks/exhaustive-deps
38
89
 
39
90
  const dateView = returnDateView(selectedDate, min, max);
40
91
  const [viewMonth, setViewMonth] = useState(dateView.getMonth());
@@ -49,11 +100,20 @@ const Calendar = ({
49
100
  setViewYear(view.getFullYear());
50
101
  }, [selectedDate?.getTime(), min?.getTime(), max?.getTime()]); // eslint-disable-line react-hooks/exhaustive-deps
51
102
 
103
+ useEffect(() => {
104
+ if (focusedDate) {
105
+ // eslint-disable-next-line react-hooks/set-state-in-effect
106
+ setViewMonth(focusedDate.getMonth());
107
+ setViewYear(focusedDate.getFullYear());
108
+ }
109
+ }, [focusedDate?.getTime()]); // eslint-disable-line react-hooks/exhaustive-deps
110
+
52
111
  const handleViewDateUpdate = useCallback(
53
112
  // eslint-disable-next-line react-hooks/preserve-manual-memoization
54
113
  ({ month, year }: { month?: number; year?: number }) => {
55
114
  if (month !== undefined) setViewMonth(month);
56
115
  if (year !== undefined) setViewYear(year);
116
+ setHasInteracted(false);
57
117
  },
58
118
  [],
59
119
  );
@@ -64,33 +124,63 @@ const Calendar = ({
64
124
 
65
125
  const handleSelectedDateUpdate = useCallback(
66
126
  (date: Date) => {
67
- if (onSelect) {
68
- onSelect(date);
69
- } else {
127
+ if (!hasInteracted) setHasInteracted(true);
128
+ const isEnabled = checkIsEnabled(date);
129
+ setFocusedDate(date);
130
+ onDateFocus?.(date, { isEnabled });
131
+ if (isEnabled && onChange) {
70
132
  onChange(date);
71
133
  }
72
134
  },
73
- [onChange, onSelect],
135
+ [onDateFocus, onChange], // eslint-disable-line react-hooks/exhaustive-deps
74
136
  );
75
137
 
76
138
  const adjustDate = useCallback(
77
139
  (daysToAdd: number, monthsToAdd: number, yearsToAdd: number) => {
140
+ const base = focusedDate ?? selectedDate;
78
141
  let date: Date | null;
79
- if (selectedDate) {
142
+ if (base) {
80
143
  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(),
144
+ mode === 'year' ? base.getFullYear() + yearsToAdd : base.getFullYear(),
145
+ mode === 'month' ? base.getMonth() + monthsToAdd : base.getMonth(),
146
+ mode === 'day' ? base.getDate() + daysToAdd : base.getDate(),
84
147
  );
85
148
  } else {
86
149
  date = getStartOfDay(new Date());
87
150
  }
88
- date &&= moveToWithinRange(date, min, max);
89
- if (date?.getTime() !== selectedDate?.getTime()) {
90
- onChange(date);
151
+ date &&=
152
+ STRICT_KEYBOARD_CLAMPING || mode !== 'day'
153
+ ? moveToWithinRange(date, min, max)
154
+ : clampToDayViewBoundary(date, min, max);
155
+ if (date?.getTime() !== focusedDate?.getTime()) {
156
+ const monthChanged = date
157
+ ? date.getMonth() !== focusedDate?.getMonth() ||
158
+ date.getFullYear() !== focusedDate?.getFullYear()
159
+ : false;
160
+ setFocusedDate(date);
161
+ if (date) {
162
+ setViewMonth(date.getMonth());
163
+ setViewYear(date.getFullYear());
164
+ }
165
+ if (monthChanged) {
166
+ setHasInteracted(false);
167
+ } else if (!hasInteracted) {
168
+ setHasInteracted(true);
169
+ }
170
+ onDateFocus?.(date, { isEnabled: checkIsEnabled(date) });
91
171
  }
92
172
  },
93
- [selectedDate, min, max, mode, onChange],
173
+ [focusedDate, selectedDate, min, max, mode, onDateFocus], // eslint-disable-line react-hooks/exhaustive-deps
174
+ );
175
+
176
+ const handleDayFocus = useCallback(
177
+ (date: Date) => {
178
+ if (date.getTime() !== focusedDate?.getTime()) {
179
+ setFocusedDate(date);
180
+ onDateFocus?.(date, { isEnabled: checkIsEnabled(date) });
181
+ }
182
+ },
183
+ [focusedDate, onDateFocus], // eslint-disable-line react-hooks/exhaustive-deps
94
184
  );
95
185
 
96
186
  const handleKeyDown = useCallback(
@@ -112,25 +202,73 @@ const Calendar = ({
112
202
  adjustDate(7, 4, 4);
113
203
  event.preventDefault();
114
204
  break;
205
+ case 'Enter':
206
+ case ' ':
207
+ if (
208
+ mode === 'day' &&
209
+ focusedDate &&
210
+ (event.target as HTMLElement).hasAttribute('data-calendar-cell')
211
+ ) {
212
+ handleSelectedDateUpdate(focusedDate);
213
+ event.preventDefault();
214
+ }
215
+ break;
115
216
  default:
116
217
  break;
117
218
  }
118
219
  },
119
- [adjustDate],
220
+ [adjustDate, focusedDate, mode, handleSelectedDateUpdate],
120
221
  );
121
222
 
223
+ const focusedDateOutOfRange = focusedDate ? !isWithinRange(focusedDate, min, max) : false;
224
+
225
+ const formatDateForPrompt = (date: Date) => formatDate(date, intl.locale, LONG_DAY_FORMAT);
226
+
227
+ const outOfRangePromptText =
228
+ min && max
229
+ ? intl.formatMessage(messages.outOfRangeBetween, {
230
+ min: formatDateForPrompt(min),
231
+ max: formatDateForPrompt(max),
232
+ })
233
+ : min
234
+ ? intl.formatMessage(messages.outOfRangeAfter, { min: formatDateForPrompt(min) })
235
+ : max
236
+ ? intl.formatMessage(messages.outOfRangeBefore, { max: formatDateForPrompt(max) })
237
+ : '';
238
+
239
+ const hasOutOfRangeDates = mode === 'day' && !!(min || max);
240
+ const showRulesPrompt =
241
+ mode === 'day' &&
242
+ !!disabledRules &&
243
+ hasDisabledDatesInMonth(viewMonth, viewYear, disabledRules, min, max);
244
+ const showOutOfRangePrompt = mode === 'day' && focusedDateOutOfRange;
245
+ const showPrompt = hasOutOfRangeDates || showRulesPrompt;
246
+
247
+ const promptText = showOutOfRangePrompt
248
+ ? outOfRangePromptText
249
+ : (disabledRules?.reason ?? outOfRangePromptText);
250
+ const promptSentiment = showOutOfRangePrompt || focusedDateDisabled ? 'negative' : 'neutral';
251
+ // Visible when there's an actionable message; sr-only when only hasOutOfRangeDates is true
252
+ // (prompt exists for aria-describedby but no visual cue needed until an out-of-range date is focused)
253
+ const promptClassName = showOutOfRangePrompt || showRulesPrompt ? 'm-t-1' : 'sr-only';
254
+
122
255
  return (
123
256
  // eslint-disable-next-line jsx-a11y/no-static-element-interactions
124
257
  <div onKeyDown={handleKeyDown}>
125
258
  {mode === 'day' && (
126
259
  <DayCalendar
127
260
  selectedDate={selectedDate}
261
+ focusedDate={focusedDate}
128
262
  min={min}
129
263
  max={max}
130
264
  viewMonth={viewMonth}
131
265
  viewYear={viewYear}
132
266
  monthFormat={monthFormat}
267
+ disabledRules={disabledRules}
268
+ disabledReasonId={showPrompt ? disabledReasonId : undefined}
269
+ announceReasonOnFocus={!hasInteracted}
133
270
  onSelect={handleSelectedDateUpdate}
271
+ onDayFocus={handleDayFocus}
134
272
  onLabelClick={switchToYears}
135
273
  onViewDateUpdate={handleViewDateUpdate}
136
274
  />
@@ -138,6 +276,7 @@ const Calendar = ({
138
276
  {mode === 'month' && (
139
277
  <MonthCalendar
140
278
  selectedDate={selectedDate}
279
+ focusedDate={focusedDate}
141
280
  min={min}
142
281
  max={max}
143
282
  viewYear={viewYear}
@@ -150,6 +289,7 @@ const Calendar = ({
150
289
  {mode === 'year' && (
151
290
  <YearCalendar
152
291
  selectedDate={selectedDate}
292
+ focusedDate={focusedDate}
153
293
  min={min}
154
294
  max={max}
155
295
  viewYear={viewYear}
@@ -158,6 +298,16 @@ const Calendar = ({
158
298
  onViewDateUpdate={handleViewDateUpdate}
159
299
  />
160
300
  )}
301
+ {showPrompt && (
302
+ <InlinePrompt
303
+ sentiment={promptSentiment}
304
+ width="full"
305
+ id={disabledReasonId}
306
+ className={promptClassName}
307
+ >
308
+ {promptText}
309
+ </InlinePrompt>
310
+ )}
161
311
  </div>
162
312
  );
163
313
  };
@@ -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}