@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.
- package/build/Calendar/Calendar.constants.js +10 -0
- package/build/Calendar/Calendar.constants.js.map +1 -0
- package/build/Calendar/Calendar.constants.mjs +8 -0
- package/build/Calendar/Calendar.constants.mjs.map +1 -0
- package/build/Calendar/Calendar.js +116 -16
- package/build/Calendar/Calendar.js.map +1 -1
- package/build/Calendar/Calendar.messages.js +12 -0
- package/build/Calendar/Calendar.messages.js.map +1 -1
- package/build/Calendar/Calendar.messages.mjs +12 -0
- package/build/Calendar/Calendar.messages.mjs.map +1 -1
- package/build/Calendar/Calendar.mjs +117 -17
- package/build/Calendar/Calendar.mjs.map +1 -1
- package/build/Calendar/DateHeader/DateHeader.js +5 -1
- package/build/Calendar/DateHeader/DateHeader.js.map +1 -1
- package/build/Calendar/DateHeader/DateHeader.mjs +5 -1
- package/build/Calendar/DateHeader/DateHeader.mjs.map +1 -1
- package/build/Calendar/DayCalendar/DayCalendar.js +20 -2
- package/build/Calendar/DayCalendar/DayCalendar.js.map +1 -1
- package/build/Calendar/DayCalendar/DayCalendar.mjs +20 -2
- package/build/Calendar/DayCalendar/DayCalendar.mjs.map +1 -1
- package/build/Calendar/DayCalendar/Table/DayCalendarTable.js +60 -19
- package/build/Calendar/DayCalendar/Table/DayCalendarTable.js.map +1 -1
- package/build/Calendar/DayCalendar/Table/DayCalendarTable.mjs +60 -19
- package/build/Calendar/DayCalendar/Table/DayCalendarTable.mjs.map +1 -1
- package/build/Calendar/MonthCalendar/MonthCalendar.js +6 -0
- package/build/Calendar/MonthCalendar/MonthCalendar.js.map +1 -1
- package/build/Calendar/MonthCalendar/MonthCalendar.mjs +6 -0
- package/build/Calendar/MonthCalendar/MonthCalendar.mjs.map +1 -1
- package/build/Calendar/MonthCalendar/Table/MonthCalendarTable.js +8 -3
- package/build/Calendar/MonthCalendar/Table/MonthCalendarTable.js.map +1 -1
- package/build/Calendar/MonthCalendar/Table/MonthCalendarTable.mjs +8 -3
- package/build/Calendar/MonthCalendar/Table/MonthCalendarTable.mjs.map +1 -1
- package/build/Calendar/TableLink/TableLink.js +91 -0
- package/build/Calendar/TableLink/TableLink.js.map +1 -0
- package/build/Calendar/TableLink/TableLink.mjs +87 -0
- package/build/Calendar/TableLink/TableLink.mjs.map +1 -0
- package/build/Calendar/YearCalendar/Table/YearCalendarTable.js +8 -3
- package/build/Calendar/YearCalendar/Table/YearCalendarTable.js.map +1 -1
- package/build/Calendar/YearCalendar/Table/YearCalendarTable.mjs +8 -3
- package/build/Calendar/YearCalendar/Table/YearCalendarTable.mjs.map +1 -1
- package/build/Calendar/YearCalendar/YearCalendar.js +7 -0
- package/build/Calendar/YearCalendar/YearCalendar.js.map +1 -1
- package/build/Calendar/YearCalendar/YearCalendar.mjs +7 -0
- package/build/Calendar/YearCalendar/YearCalendar.mjs.map +1 -1
- package/build/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.js +33 -0
- package/build/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.js.map +1 -0
- package/build/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.mjs +31 -0
- package/build/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.mjs.map +1 -0
- package/build/Calendar/_helpers/isDateDisabled/isDateDisabled.js +40 -0
- package/build/Calendar/_helpers/isDateDisabled/isDateDisabled.js.map +1 -0
- package/build/Calendar/_helpers/isDateDisabled/isDateDisabled.mjs +38 -0
- package/build/Calendar/_helpers/isDateDisabled/isDateDisabled.mjs.map +1 -0
- package/build/DateLookup/DateLookup.js +20 -9
- package/build/DateLookup/DateLookup.js.map +1 -1
- package/build/DateLookup/DateLookup.mjs +20 -9
- package/build/DateLookup/DateLookup.mjs.map +1 -1
- package/build/i18n/en.json +4 -0
- package/build/i18n/en.json.js +4 -0
- package/build/i18n/en.json.js.map +1 -1
- package/build/i18n/en.json.mjs +4 -0
- package/build/i18n/en.json.mjs.map +1 -1
- package/build/i18n/hu.json +1 -1
- package/build/i18n/hu.json.js +1 -1
- package/build/i18n/hu.json.mjs +1 -1
- package/build/index.js +2 -2
- package/build/index.mjs +1 -1
- package/build/main.css +123 -43
- package/build/styles/Calendar/Calendar.css +112 -37
- package/build/styles/Calendar/TableLink/TableLink.css +115 -0
- package/build/styles/main.css +123 -43
- package/build/types/Calendar/Calendar.constants.d.ts +2 -0
- package/build/types/Calendar/Calendar.constants.d.ts.map +1 -0
- package/build/types/Calendar/Calendar.d.ts +16 -3
- package/build/types/Calendar/Calendar.d.ts.map +1 -1
- package/build/types/Calendar/Calendar.messages.d.ts +20 -0
- package/build/types/Calendar/Calendar.messages.d.ts.map +1 -1
- package/build/types/Calendar/Calendar.types.d.ts +32 -0
- package/build/types/Calendar/Calendar.types.d.ts.map +1 -0
- package/build/types/Calendar/DateHeader/DateHeader.d.ts +2 -0
- package/build/types/Calendar/DateHeader/DateHeader.d.ts.map +1 -1
- package/build/types/Calendar/DayCalendar/DayCalendar.d.ts +7 -1
- package/build/types/Calendar/DayCalendar/DayCalendar.d.ts.map +1 -1
- package/build/types/Calendar/DayCalendar/Table/DayCalendarTable.d.ts +6 -0
- package/build/types/Calendar/DayCalendar/Table/DayCalendarTable.d.ts.map +1 -1
- package/build/types/Calendar/MonthCalendar/MonthCalendar.d.ts +1 -0
- package/build/types/Calendar/MonthCalendar/MonthCalendar.d.ts.map +1 -1
- package/build/types/Calendar/MonthCalendar/Table/MonthCalendarTable.d.ts +2 -1
- package/build/types/Calendar/MonthCalendar/Table/MonthCalendarTable.d.ts.map +1 -1
- package/build/types/Calendar/{tableLink → TableLink}/TableLink.d.ts +5 -1
- package/build/types/Calendar/TableLink/TableLink.d.ts.map +1 -0
- package/build/types/Calendar/TableLink/index.d.ts.map +1 -0
- package/build/types/Calendar/YearCalendar/Table/YearCalendarTable.d.ts +2 -1
- package/build/types/Calendar/YearCalendar/Table/YearCalendarTable.d.ts.map +1 -1
- package/build/types/Calendar/YearCalendar/YearCalendar.d.ts +1 -0
- package/build/types/Calendar/YearCalendar/YearCalendar.d.ts.map +1 -1
- package/build/types/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.d.ts +3 -0
- package/build/types/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.d.ts.map +1 -0
- package/build/types/Calendar/_helpers/index.d.ts +3 -0
- package/build/types/Calendar/_helpers/index.d.ts.map +1 -0
- package/build/types/Calendar/_helpers/isDateDisabled/isDateDisabled.d.ts +3 -0
- package/build/types/Calendar/_helpers/isDateDisabled/isDateDisabled.d.ts.map +1 -0
- package/build/types/Calendar/index.d.ts +1 -0
- package/build/types/Calendar/index.d.ts.map +1 -1
- package/build/types/DateLookup/DateLookup.d.ts +24 -5
- package/build/types/DateLookup/DateLookup.d.ts.map +1 -1
- package/build/types/DateLookup/index.d.ts +1 -0
- package/build/types/DateLookup/index.d.ts.map +1 -1
- package/build/types/index.d.ts +3 -2
- package/build/types/index.d.ts.map +1 -1
- package/build/types/test-utils/index.d.ts +8 -0
- package/build/types/test-utils/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/Calendar/Calendar.constants.ts +5 -0
- package/src/Calendar/Calendar.css +112 -37
- package/src/Calendar/Calendar.disabledDates.test.tsx +593 -0
- package/src/Calendar/Calendar.less +5 -35
- package/src/Calendar/Calendar.messages.ts +21 -0
- package/src/Calendar/Calendar.test.tsx +310 -28
- package/src/Calendar/Calendar.tsx +159 -18
- package/src/Calendar/Calendar.types.ts +35 -0
- package/src/Calendar/DateHeader/DateHeader.tsx +6 -0
- package/src/Calendar/DayCalendar/DayCalendar.tsx +39 -1
- package/src/Calendar/DayCalendar/Table/DayCalendarTable.tsx +67 -27
- package/src/Calendar/MonthCalendar/MonthCalendar.tsx +8 -0
- package/src/Calendar/MonthCalendar/Table/MonthCalendarTable.tsx +15 -3
- package/src/Calendar/TableLink/TableLink.css +115 -0
- package/src/Calendar/TableLink/TableLink.less +68 -0
- package/src/Calendar/TableLink/TableLink.tsx +112 -0
- package/src/Calendar/YearCalendar/Table/YearCalendarTable.tsx +15 -3
- package/src/Calendar/YearCalendar/YearCalendar.tsx +9 -0
- package/src/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.test.ts +98 -0
- package/src/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.ts +45 -0
- package/src/Calendar/_helpers/index.ts +2 -0
- package/src/Calendar/_helpers/isDateDisabled/isDateDisabled.test.ts +169 -0
- package/src/Calendar/_helpers/isDateDisabled/isDateDisabled.ts +59 -0
- package/src/Calendar/_stories/Calendar.accessibility.docs.mdx +118 -0
- package/src/Calendar/_stories/Calendar.story.tsx +138 -4
- package/src/Calendar/_stories/Calendar.test.story.tsx +114 -0
- package/src/Calendar/index.ts +7 -0
- package/src/DateLookup/DateLookup.disabledDates.test.tsx +293 -0
- package/src/DateLookup/DateLookup.test.tsx +34 -37
- package/src/DateLookup/DateLookup.tsx +37 -13
- package/src/DateLookup/_stories/DateLookup.accessibility.docs.mdx +108 -0
- package/src/DateLookup/_stories/DateLookup.story.tsx +124 -2
- package/src/DateLookup/index.ts +6 -0
- package/src/i18n/en.json +4 -0
- package/src/i18n/hu.json +1 -1
- package/src/index.ts +3 -2
- package/src/main.css +123 -43
- package/build/Calendar/tableLink/TableLink.js +0 -61
- package/build/Calendar/tableLink/TableLink.js.map +0 -1
- package/build/Calendar/tableLink/TableLink.mjs +0 -57
- package/build/Calendar/tableLink/TableLink.mjs.map +0 -1
- package/build/types/Calendar/tableLink/TableLink.d.ts.map +0 -1
- package/build/types/Calendar/tableLink/index.d.ts.map +0 -1
- package/src/Calendar/tableLink/TableLink.tsx +0 -77
- /package/build/types/Calendar/{tableLink → TableLink}/index.d.ts +0 -0
- /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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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(),
|
|
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 (
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
[
|
|
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 (
|
|
136
|
+
if (base) {
|
|
80
137
|
date = new Date(
|
|
81
|
-
mode === 'year' ?
|
|
82
|
-
mode === 'month' ?
|
|
83
|
-
mode === 'day' ?
|
|
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() !==
|
|
90
|
-
|
|
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,
|
|
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
|
|
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 '../../
|
|
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
|
-
|
|
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.
|
|
117
|
+
isActive: this.isFocused,
|
|
90
118
|
isNow: this.isToday,
|
|
91
|
-
isDisabled: this.
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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}
|