@transferwise/components 0.0.0-experimental-bcea1dd → 0.0.0-experimental-1168b1c
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.js +47 -8
- package/build/Calendar/Calendar.js.map +1 -1
- package/build/Calendar/Calendar.messages.js +3 -0
- package/build/Calendar/Calendar.messages.js.map +1 -1
- package/build/Calendar/Calendar.messages.mjs +3 -0
- package/build/Calendar/Calendar.messages.mjs.map +1 -1
- package/build/Calendar/Calendar.mjs +48 -9
- package/build/Calendar/Calendar.mjs.map +1 -1
- package/build/Calendar/DayCalendar/DayCalendar.js +4 -0
- package/build/Calendar/DayCalendar/DayCalendar.js.map +1 -1
- package/build/Calendar/DayCalendar/DayCalendar.mjs +4 -0
- package/build/Calendar/DayCalendar/DayCalendar.mjs.map +1 -1
- package/build/Calendar/DayCalendar/Table/DayCalendarTable.js +30 -18
- package/build/Calendar/DayCalendar/Table/DayCalendarTable.js.map +1 -1
- package/build/Calendar/DayCalendar/Table/DayCalendarTable.mjs +30 -18
- package/build/Calendar/DayCalendar/Table/DayCalendarTable.mjs.map +1 -1
- package/build/Calendar/MonthCalendar/Table/MonthCalendarTable.js +1 -1
- package/build/Calendar/MonthCalendar/Table/MonthCalendarTable.js.map +1 -1
- package/build/Calendar/MonthCalendar/Table/MonthCalendarTable.mjs +1 -1
- package/build/Calendar/MonthCalendar/Table/MonthCalendarTable.mjs.map +1 -1
- package/build/Calendar/{tableLink → TableLink}/TableLink.js +23 -11
- package/build/Calendar/TableLink/TableLink.js.map +1 -0
- package/build/Calendar/{tableLink → TableLink}/TableLink.mjs +23 -11
- package/build/Calendar/TableLink/TableLink.mjs.map +1 -0
- package/build/Calendar/YearCalendar/Table/YearCalendarTable.js +1 -1
- package/build/Calendar/YearCalendar/Table/YearCalendarTable.js.map +1 -1
- package/build/Calendar/YearCalendar/Table/YearCalendarTable.mjs +1 -1
- package/build/Calendar/YearCalendar/Table/YearCalendarTable.mjs.map +1 -1
- package/build/DateLookup/DateLookup.js +14 -4
- package/build/DateLookup/DateLookup.js.map +1 -1
- package/build/DateLookup/DateLookup.mjs +14 -4
- package/build/DateLookup/DateLookup.mjs.map +1 -1
- package/build/common/DateUtils/hasDisabledDatesInMonth/hasDisabledDatesInMonth.js +33 -0
- package/build/common/DateUtils/hasDisabledDatesInMonth/hasDisabledDatesInMonth.js.map +1 -0
- package/build/common/DateUtils/hasDisabledDatesInMonth/hasDisabledDatesInMonth.mjs +31 -0
- package/build/common/DateUtils/hasDisabledDatesInMonth/hasDisabledDatesInMonth.mjs.map +1 -0
- package/build/common/DateUtils/isDateDisabled/isDateDisabled.js +40 -0
- package/build/common/DateUtils/isDateDisabled/isDateDisabled.js.map +1 -0
- package/build/common/DateUtils/isDateDisabled/isDateDisabled.mjs +38 -0
- package/build/common/DateUtils/isDateDisabled/isDateDisabled.mjs.map +1 -0
- package/build/i18n/en.json +1 -0
- package/build/i18n/en.json.js +1 -0
- package/build/i18n/en.json.js.map +1 -1
- package/build/i18n/en.json.mjs +1 -0
- package/build/i18n/en.json.mjs.map +1 -1
- package/build/index.js +2 -4
- package/build/index.js.map +1 -1
- package/build/index.mjs +1 -2
- package/build/index.mjs.map +1 -1
- package/build/main.css +114 -76
- package/build/styles/Calendar/Calendar.css +104 -35
- package/build/styles/Calendar/TableLink/TableLink.css +109 -0
- package/build/styles/main.css +114 -76
- package/build/types/Body/Body.d.ts +2 -2
- package/build/types/Calendar/Calendar.d.ts +4 -2
- package/build/types/Calendar/Calendar.d.ts.map +1 -1
- package/build/types/Calendar/Calendar.messages.d.ts +5 -0
- package/build/types/Calendar/Calendar.messages.d.ts.map +1 -1
- package/build/types/Calendar/Calendar.types.d.ts +19 -0
- package/build/types/Calendar/Calendar.types.d.ts.map +1 -0
- package/build/types/Calendar/DayCalendar/DayCalendar.d.ts +4 -1
- package/build/types/Calendar/DayCalendar/DayCalendar.d.ts.map +1 -1
- package/build/types/Calendar/DayCalendar/Table/DayCalendarTable.d.ts +3 -0
- package/build/types/Calendar/DayCalendar/Table/DayCalendarTable.d.ts.map +1 -1
- package/build/types/Calendar/{tableLink → TableLink}/TableLink.d.ts +2 -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/index.d.ts +1 -0
- package/build/types/Calendar/index.d.ts.map +1 -1
- package/build/types/DateLookup/DateLookup.d.ts +5 -2
- 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/IconButton/IconButton.d.ts +1 -1
- package/build/types/Title/Title.d.ts +2 -2
- package/build/types/Upload/Steps/UploadImageStep/UploadImageStep.d.ts +1 -1
- package/build/types/UploadInput/UploadItem/UploadItemLink.d.ts +1 -1
- package/build/types/common/DateUtils/hasDisabledDatesInMonth/hasDisabledDatesInMonth.d.ts +3 -0
- package/build/types/common/DateUtils/hasDisabledDatesInMonth/hasDisabledDatesInMonth.d.ts.map +1 -0
- package/build/types/common/DateUtils/index.d.ts +2 -0
- package/build/types/common/DateUtils/index.d.ts.map +1 -1
- package/build/types/common/DateUtils/isDateDisabled/isDateDisabled.d.ts +3 -0
- package/build/types/common/DateUtils/isDateDisabled/isDateDisabled.d.ts.map +1 -0
- package/build/types/index.d.ts +2 -4
- package/build/types/index.d.ts.map +1 -1
- package/build/types/test-utils/index.d.ts +2 -0
- package/build/types/test-utils/index.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/Calendar/Calendar.css +104 -35
- package/src/Calendar/Calendar.disabledDates.test.tsx +197 -0
- package/src/Calendar/Calendar.less +5 -35
- package/src/Calendar/Calendar.messages.ts +5 -0
- package/src/Calendar/Calendar.test.tsx +9 -9
- package/src/Calendar/Calendar.tsx +61 -9
- package/src/Calendar/Calendar.types.ts +22 -0
- package/src/Calendar/DayCalendar/DayCalendar.tsx +17 -1
- package/src/Calendar/DayCalendar/Table/DayCalendarTable.tsx +42 -26
- package/src/Calendar/MonthCalendar/Table/MonthCalendarTable.tsx +1 -1
- package/src/Calendar/TableLink/TableLink.css +109 -0
- package/src/Calendar/TableLink/TableLink.less +60 -0
- package/src/Calendar/{tableLink → TableLink}/TableLink.tsx +24 -14
- package/src/Calendar/YearCalendar/Table/YearCalendarTable.tsx +1 -1
- package/src/Calendar/_stories/Calendar.accessibility.docs.mdx +112 -0
- package/src/Calendar/_stories/Calendar.story.tsx +55 -1
- package/src/Calendar/index.ts +7 -0
- package/src/DateLookup/DateLookup.disabledDates.test.tsx +120 -0
- package/src/DateLookup/DateLookup.test.tsx +1 -1
- package/src/DateLookup/DateLookup.tsx +11 -4
- package/src/DateLookup/_stories/DateLookup.accessibility.docs.mdx +100 -0
- package/src/DateLookup/_stories/DateLookup.story.tsx +59 -1
- package/src/DateLookup/index.ts +6 -0
- package/src/common/DateUtils/hasDisabledDatesInMonth/hasDisabledDatesInMonth.test.ts +98 -0
- package/src/common/DateUtils/hasDisabledDatesInMonth/hasDisabledDatesInMonth.ts +45 -0
- package/src/common/DateUtils/index.ts +2 -0
- package/src/common/DateUtils/isDateDisabled/isDateDisabled.test.ts +159 -0
- package/src/common/DateUtils/isDateDisabled/isDateDisabled.ts +59 -0
- package/src/i18n/en.json +1 -0
- package/src/index.ts +2 -4
- package/src/main.css +114 -76
- package/src/main.less +0 -1
- package/build/Calendar/tableLink/TableLink.js.map +0 -1
- package/build/Calendar/tableLink/TableLink.mjs.map +0 -1
- package/build/Markup/Markup.js +0 -136
- package/build/Markup/Markup.js.map +0 -1
- package/build/Markup/Markup.mjs +0 -131
- package/build/Markup/Markup.mjs.map +0 -1
- package/build/Markup/constants.js +0 -36
- package/build/Markup/constants.js.map +0 -1
- package/build/Markup/constants.mjs +0 -29
- package/build/Markup/constants.mjs.map +0 -1
- package/build/Markup/utils/parseMarkup.js +0 -226
- package/build/Markup/utils/parseMarkup.js.map +0 -1
- package/build/Markup/utils/parseMarkup.mjs +0 -223
- package/build/Markup/utils/parseMarkup.mjs.map +0 -1
- package/build/Markup/utils/sanitise.js +0 -91
- package/build/Markup/utils/sanitise.js.map +0 -1
- package/build/Markup/utils/sanitise.mjs +0 -89
- package/build/Markup/utils/sanitise.mjs.map +0 -1
- package/build/styles/Markup/Markup.css +0 -28
- package/build/styles/Markup/_stories/Vulnerability/Vulnerability.css +0 -70
- package/build/types/Calendar/tableLink/TableLink.d.ts.map +0 -1
- package/build/types/Calendar/tableLink/index.d.ts.map +0 -1
- package/build/types/Markup/Markup.d.ts +0 -18
- package/build/types/Markup/Markup.d.ts.map +0 -1
- package/build/types/Markup/Markup.types.d.ts +0 -68
- package/build/types/Markup/Markup.types.d.ts.map +0 -1
- package/build/types/Markup/_stories/Vulnerability/Vulnerability.d.ts +0 -12
- package/build/types/Markup/_stories/Vulnerability/Vulnerability.d.ts.map +0 -1
- package/build/types/Markup/constants.d.ts +0 -20
- package/build/types/Markup/constants.d.ts.map +0 -1
- package/build/types/Markup/index.d.ts +0 -3
- package/build/types/Markup/index.d.ts.map +0 -1
- package/build/types/Markup/utils/parseMarkup.d.ts +0 -6
- package/build/types/Markup/utils/parseMarkup.d.ts.map +0 -1
- package/build/types/Markup/utils/sanitise.d.ts +0 -2
- package/build/types/Markup/utils/sanitise.d.ts.map +0 -1
- package/src/Markup/Markup.css +0 -28
- package/src/Markup/Markup.injection.test.tsx +0 -152
- package/src/Markup/Markup.less +0 -29
- package/src/Markup/Markup.test.tsx +0 -405
- package/src/Markup/Markup.tsx +0 -165
- package/src/Markup/Markup.types.ts +0 -53
- package/src/Markup/_stories/Markup.docs.mdx +0 -75
- package/src/Markup/_stories/Markup.security.docs.mdx +0 -256
- package/src/Markup/_stories/Markup.story.tsx +0 -227
- package/src/Markup/_stories/Vulnerability/Vulnerability.css +0 -70
- package/src/Markup/_stories/Vulnerability/Vulnerability.tsx +0 -30
- package/src/Markup/constants.ts +0 -41
- package/src/Markup/index.ts +0 -2
- package/src/Markup/utils/parseMarkup.test.ts +0 -658
- package/src/Markup/utils/parseMarkup.ts +0 -228
- package/src/Markup/utils/sanitise.test.ts +0 -375
- package/src/Markup/utils/sanitise.ts +0 -107
- /package/build/types/Calendar/{tableLink → TableLink}/index.d.ts +0 -0
- /package/src/Calendar/{tableLink → TableLink}/index.ts +0 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Meta } from '@storybook/addon-docs/blocks';
|
|
2
|
+
|
|
3
|
+
<Meta title="Forms/Calendar/Accessibility" />
|
|
4
|
+
|
|
5
|
+
# Accessibility
|
|
6
|
+
|
|
7
|
+
Calendar is a fully keyboard-accessible date picker grid. All dates — including disabled ones — are reachable via keyboard so that screen reader users can discover which dates are unavailable and why.
|
|
8
|
+
|
|
9
|
+
## Keyboard Support
|
|
10
|
+
|
|
11
|
+
<table>
|
|
12
|
+
<thead>
|
|
13
|
+
<tr>
|
|
14
|
+
<th>Key</th>
|
|
15
|
+
<th>Action</th>
|
|
16
|
+
</tr>
|
|
17
|
+
</thead>
|
|
18
|
+
<tbody>
|
|
19
|
+
<tr>
|
|
20
|
+
<td>
|
|
21
|
+
<code>ArrowLeft</code>
|
|
22
|
+
</td>
|
|
23
|
+
<td>Move focus to previous day / month / year</td>
|
|
24
|
+
</tr>
|
|
25
|
+
<tr>
|
|
26
|
+
<td>
|
|
27
|
+
<code>ArrowRight</code>
|
|
28
|
+
</td>
|
|
29
|
+
<td>Move focus to next day / month / year</td>
|
|
30
|
+
</tr>
|
|
31
|
+
<tr>
|
|
32
|
+
<td>
|
|
33
|
+
<code>ArrowUp</code>
|
|
34
|
+
</td>
|
|
35
|
+
<td>Move focus up by one week (7 days) / 4 months / 4 years</td>
|
|
36
|
+
</tr>
|
|
37
|
+
<tr>
|
|
38
|
+
<td>
|
|
39
|
+
<code>ArrowDown</code>
|
|
40
|
+
</td>
|
|
41
|
+
<td>Move focus down by one week (7 days) / 4 months / 4 years</td>
|
|
42
|
+
</tr>
|
|
43
|
+
<tr>
|
|
44
|
+
<td>
|
|
45
|
+
<code>Enter</code> / <code>Space</code>
|
|
46
|
+
</td>
|
|
47
|
+
<td>Select the focused date (if enabled)</td>
|
|
48
|
+
</tr>
|
|
49
|
+
</tbody>
|
|
50
|
+
</table>
|
|
51
|
+
|
|
52
|
+
<br /> Navigation **does not skip** disabled dates. Users can arrow through every date in the grid
|
|
53
|
+
regardless of its enabled state.
|
|
54
|
+
|
|
55
|
+
## Screen Reader Announcements
|
|
56
|
+
|
|
57
|
+
Each date cell is a `<button>` with an `aria-label` in a human-readable long format (e.g. "6 July 2024") constructed via `Intl.DateTimeFormat` for full locale support.
|
|
58
|
+
|
|
59
|
+
### Active (Selected) Date
|
|
60
|
+
|
|
61
|
+
The currently selected date includes "selected day" in its aria-label:
|
|
62
|
+
|
|
63
|
+
> "6 July 2024, selected day"
|
|
64
|
+
|
|
65
|
+
### Disabled Dates
|
|
66
|
+
|
|
67
|
+
Disabled date cells are announced with additional context:
|
|
68
|
+
|
|
69
|
+
- `aria-disabled="true"` signals the date is not selectable
|
|
70
|
+
- The aria-label appends "not available" (e.g. "6 July 2024, not available")
|
|
71
|
+
- `aria-describedby` links to the InlinePrompt element containing the reason text
|
|
72
|
+
|
|
73
|
+
This means a screen reader will announce something like:
|
|
74
|
+
|
|
75
|
+
> "6 July 2024, not available — We are closed on weekends. button, dimmed."
|
|
76
|
+
|
|
77
|
+
### Disabled + Selected
|
|
78
|
+
|
|
79
|
+
If the focused date is both active and disabled:
|
|
80
|
+
|
|
81
|
+
> "6 July 2024, selected day, not available"
|
|
82
|
+
|
|
83
|
+
## InlinePrompt Behaviour
|
|
84
|
+
|
|
85
|
+
An `InlinePrompt` renders below the day grid when the current month contains disabled dates. It displays the `disabledRules.reason` text.
|
|
86
|
+
|
|
87
|
+
### When It Appears
|
|
88
|
+
|
|
89
|
+
The prompt is visible when **all** of the following are true:
|
|
90
|
+
|
|
91
|
+
1. The Calendar is in day view
|
|
92
|
+
2. `disabledRules.reason` is provided
|
|
93
|
+
3. The currently displayed month contains at least one disabled date
|
|
94
|
+
|
|
95
|
+
### How It's Announced
|
|
96
|
+
|
|
97
|
+
The prompt is linked to disabled date cells via `aria-describedby`. This means the reason text is read as a description when a disabled cell receives focus — screen readers announce it after the label and role.
|
|
98
|
+
|
|
99
|
+
The prompt element has an `id` that matches the `aria-describedby` on each disabled cell. It is **not** a live region (`role="alert"` or `role="status"`) because:
|
|
100
|
+
|
|
101
|
+
- It does not change dynamically while focus is on a disabled cell
|
|
102
|
+
- Announcing it on every arrow key press would be disruptive
|
|
103
|
+
- The `aria-describedby` relationship already ensures it's read on focus
|
|
104
|
+
|
|
105
|
+
### Sentiment
|
|
106
|
+
|
|
107
|
+
The prompt's visual sentiment changes based on the currently focused date:
|
|
108
|
+
|
|
109
|
+
- **Negative** (red) — when the focused date is disabled
|
|
110
|
+
- **Neutral** — when the focused date is enabled
|
|
111
|
+
|
|
112
|
+
This is a visual-only change and does not affect screen reader output.
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
createSandboxStory,
|
|
6
6
|
globalScope,
|
|
7
7
|
} from '../../../.storybook/components/sandbox/SandboxEditor';
|
|
8
|
+
import { storySourceWithoutNoise } from '../../../.storybook/helpers';
|
|
8
9
|
import Calendar, { type CalendarProps } from '../Calendar';
|
|
9
10
|
import Modal from '../../Modal';
|
|
10
11
|
import Button from '../../Button';
|
|
@@ -30,12 +31,17 @@ thePast.setUTCDate(epoch.getUTCDate() - 10);
|
|
|
30
31
|
|
|
31
32
|
export const Sandbox = createSandboxStory({
|
|
32
33
|
code: `const App = () => {
|
|
33
|
-
const [value, setValue] = React.useState(new Date(
|
|
34
|
+
const [value, setValue] = React.useState(new Date(2024, 5, 18));
|
|
34
35
|
|
|
35
36
|
return (
|
|
36
37
|
<Calendar
|
|
37
38
|
monthFormat="long"
|
|
38
39
|
value={value}
|
|
40
|
+
disabledRules={{
|
|
41
|
+
weekdays: ['sat', 'sun'],
|
|
42
|
+
dates: [new Date(2024, 5, 19)],
|
|
43
|
+
reason: 'Weekends and holidays are unavailable.',
|
|
44
|
+
}}
|
|
39
45
|
onChange={(date) => setValue(date)}
|
|
40
46
|
/>
|
|
41
47
|
);
|
|
@@ -102,3 +108,51 @@ export const InsideModal: Story = {
|
|
|
102
108
|
await userEvent.click(canvas.getByRole('button', { name: 'Open modal' }));
|
|
103
109
|
},
|
|
104
110
|
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Use `disabledRules` to disable specific weekdays, individual dates, or even date ranges.
|
|
114
|
+
* For accessibility purposes, the `reason` field is required and renders as an InlinePrompt
|
|
115
|
+
* below the calendar grid.
|
|
116
|
+
*
|
|
117
|
+
* If you want to customise the `reason` on per-date basis, use the `isEnabled` meta
|
|
118
|
+
* from `onChange` to determine and update the reason dynamically.
|
|
119
|
+
*/
|
|
120
|
+
export const DisabledDates: Story = storySourceWithoutNoise({
|
|
121
|
+
render: function Render() {
|
|
122
|
+
const DEFAULT_REASON = 'Some dates are unavailable for this service.';
|
|
123
|
+
const bankHolidays = [new Date(2025, 6, 4), new Date(2025, 6, 14)];
|
|
124
|
+
const [value, setValue] = useState<Date | null>(new Date(2025, 6, 15));
|
|
125
|
+
const [reason, setReason] = useState(DEFAULT_REASON);
|
|
126
|
+
|
|
127
|
+
function determineReason(date: Date, isEnabled: boolean): string {
|
|
128
|
+
if (isEnabled) return DEFAULT_REASON;
|
|
129
|
+
if (bankHolidays.some((holiday) => holiday.toDateString() === date.toDateString())) {
|
|
130
|
+
return 'This date is a bank holiday.';
|
|
131
|
+
}
|
|
132
|
+
if (date.getDay() === 0 || date.getDay() === 6) {
|
|
133
|
+
return 'We do not operate on weekends.';
|
|
134
|
+
}
|
|
135
|
+
return 'This date is unavailable due to scheduled maintenance.';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<Calendar
|
|
140
|
+
value={value}
|
|
141
|
+
min={new Date(2025, 6, 1)}
|
|
142
|
+
max={new Date(2025, 7, 31)}
|
|
143
|
+
disabledRules={{
|
|
144
|
+
weekdays: ['sat', 'sun'],
|
|
145
|
+
dates: bankHolidays,
|
|
146
|
+
ranges: [{ start: new Date(2025, 7, 11), end: new Date(2025, 7, 15) }],
|
|
147
|
+
reason,
|
|
148
|
+
}}
|
|
149
|
+
onChange={(date, { isEnabled }) => {
|
|
150
|
+
setValue(date);
|
|
151
|
+
if (date) {
|
|
152
|
+
setReason(determineReason(date, isEnabled));
|
|
153
|
+
}
|
|
154
|
+
}}
|
|
155
|
+
/>
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
});
|
package/src/Calendar/index.ts
CHANGED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { mockMatchMedia, mockResizeObserver, render, screen, userEvent } from '../test-utils';
|
|
2
|
+
import DateLookup from './DateLookup';
|
|
3
|
+
import type { DisabledRules } from '../Calendar/Calendar.types';
|
|
4
|
+
|
|
5
|
+
import { act } from 'react';
|
|
6
|
+
|
|
7
|
+
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTimeAsync });
|
|
8
|
+
|
|
9
|
+
mockMatchMedia();
|
|
10
|
+
mockResizeObserver();
|
|
11
|
+
|
|
12
|
+
const initialValue = new Date(2024, 6, 1);
|
|
13
|
+
|
|
14
|
+
const weekendRules: DisabledRules = {
|
|
15
|
+
weekdays: ['sat', 'sun'],
|
|
16
|
+
reason: 'We are closed on weekends.',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const bankHolidayRules: DisabledRules = {
|
|
20
|
+
dates: [new Date(2024, 6, 4)],
|
|
21
|
+
reason: 'Bank holiday.',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe('DateLookup - disabled dates', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
jest.useFakeTimers();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
await jest.runOnlyPendingTimersAsync();
|
|
31
|
+
jest.useRealTimers();
|
|
32
|
+
jest.clearAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const openDateLookup = async () => {
|
|
36
|
+
await user.click(screen.getByRole('button'));
|
|
37
|
+
await act(async () => {
|
|
38
|
+
await jest.runOnlyPendingTimersAsync();
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
describe('panel behavior', () => {
|
|
43
|
+
it('does NOT close when a disabled date is clicked', async () => {
|
|
44
|
+
render(
|
|
45
|
+
<DateLookup value={initialValue} disabledRules={bankHolidayRules} onChange={jest.fn()} />,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
await openDateLookup();
|
|
49
|
+
const holiday = screen.getByLabelText(/^4 July 2024/u);
|
|
50
|
+
await user.click(holiday);
|
|
51
|
+
|
|
52
|
+
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('closes when an enabled date is clicked', async () => {
|
|
56
|
+
const handleChange = jest.fn();
|
|
57
|
+
render(
|
|
58
|
+
<DateLookup
|
|
59
|
+
value={initialValue}
|
|
60
|
+
disabledRules={bankHolidayRules}
|
|
61
|
+
onChange={handleChange}
|
|
62
|
+
/>,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
await openDateLookup();
|
|
66
|
+
const enabledDate = screen.getByLabelText(/^3 July 2024/u);
|
|
67
|
+
await user.click(enabledDate);
|
|
68
|
+
|
|
69
|
+
await act(async () => {
|
|
70
|
+
await jest.runOnlyPendingTimersAsync();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
|
74
|
+
expect(handleChange).toHaveBeenCalledWith(new Date(2024, 6, 3));
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('prop threading', () => {
|
|
79
|
+
it('renders disabled dates with aria-disabled in the calendar', async () => {
|
|
80
|
+
render(<DateLookup value={initialValue} disabledRules={weekendRules} onChange={jest.fn()} />);
|
|
81
|
+
|
|
82
|
+
await openDateLookup();
|
|
83
|
+
|
|
84
|
+
const saturday = screen.getByLabelText(/^6 July 2024/u);
|
|
85
|
+
expect(saturday).toHaveAttribute('aria-disabled', 'true');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('renders InlinePrompt with reason text', async () => {
|
|
89
|
+
render(<DateLookup value={initialValue} disabledRules={weekendRules} onChange={jest.fn()} />);
|
|
90
|
+
|
|
91
|
+
await openDateLookup();
|
|
92
|
+
|
|
93
|
+
expect(screen.getByText('We are closed on weekends.')).toBeInTheDocument();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('onDateSelect', () => {
|
|
98
|
+
it('fires when navigating with arrow keys', async () => {
|
|
99
|
+
const handleDateSelect = jest.fn();
|
|
100
|
+
render(
|
|
101
|
+
<DateLookup
|
|
102
|
+
value={new Date(2024, 6, 5)}
|
|
103
|
+
disabledRules={weekendRules}
|
|
104
|
+
onChange={jest.fn()}
|
|
105
|
+
onDateSelect={handleDateSelect}
|
|
106
|
+
/>,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
await openDateLookup();
|
|
110
|
+
await user.keyboard('{ArrowRight}');
|
|
111
|
+
|
|
112
|
+
expect(handleDateSelect).toHaveBeenCalledWith(
|
|
113
|
+
expect.objectContaining({
|
|
114
|
+
date: new Date(2024, 6, 6),
|
|
115
|
+
isEnabled: false,
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -229,7 +229,7 @@ describe('DateLookup (events)', () => {
|
|
|
229
229
|
const today = new Date();
|
|
230
230
|
const todayDateLabel = today.toLocaleDateString('en-GB', {
|
|
231
231
|
day: 'numeric',
|
|
232
|
-
month: '
|
|
232
|
+
month: 'long',
|
|
233
233
|
year: 'numeric',
|
|
234
234
|
});
|
|
235
235
|
const expectedHeaderText = today.toLocaleDateString('en-GB', {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { clsx } from 'clsx';
|
|
2
2
|
import { createRef, PureComponent, KeyboardEvent } from 'react';
|
|
3
3
|
import Calendar from '../Calendar';
|
|
4
|
+
import type { DateChangeMeta, DisabledRules, OnDateSelectParams } from '../Calendar/Calendar.types';
|
|
4
5
|
import {
|
|
5
6
|
Size,
|
|
6
7
|
MonthFormat,
|
|
@@ -26,7 +27,9 @@ export interface DateLookupProps {
|
|
|
26
27
|
monthFormat?: `${MonthFormat}`;
|
|
27
28
|
disabled?: boolean;
|
|
28
29
|
clearable?: boolean;
|
|
30
|
+
disabledRules?: DisabledRules;
|
|
29
31
|
onChange: (date: Date | null) => void;
|
|
32
|
+
onDateSelect?: (params: OnDateSelectParams) => void;
|
|
30
33
|
onFocus?: () => void;
|
|
31
34
|
onBlur?: () => void;
|
|
32
35
|
}
|
|
@@ -128,7 +131,8 @@ class DateLookup extends PureComponent<DateLookupPropsWithInputAttributes, DateL
|
|
|
128
131
|
}
|
|
129
132
|
};
|
|
130
133
|
|
|
131
|
-
handleDateChange = (date: Date | null) => {
|
|
134
|
+
handleDateChange = (date: Date | null, meta: DateChangeMeta) => {
|
|
135
|
+
const { onDateSelect } = this.props;
|
|
132
136
|
this.setState(
|
|
133
137
|
(state) => {
|
|
134
138
|
const originalDate = state.originalDate ?? this.props.value;
|
|
@@ -136,18 +140,20 @@ class DateLookup extends PureComponent<DateLookupPropsWithInputAttributes, DateL
|
|
|
136
140
|
},
|
|
137
141
|
() => {
|
|
138
142
|
this.props.onChange(date);
|
|
143
|
+
if (date && onDateSelect) {
|
|
144
|
+
onDateSelect({ date, isEnabled: meta.isEnabled });
|
|
145
|
+
}
|
|
139
146
|
},
|
|
140
147
|
);
|
|
141
148
|
};
|
|
142
149
|
|
|
143
|
-
handleDateSelect = (
|
|
144
|
-
this.props.onChange(date);
|
|
150
|
+
handleDateSelect = () => {
|
|
145
151
|
this.close();
|
|
146
152
|
this.focusOn('.btn');
|
|
147
153
|
};
|
|
148
154
|
|
|
149
155
|
getCalendar = () => {
|
|
150
|
-
const { value, min, max, monthFormat, placeholder } = this.props;
|
|
156
|
+
const { value, min, max, monthFormat, placeholder, disabledRules } = this.props;
|
|
151
157
|
return (
|
|
152
158
|
<div className="p-a-1">
|
|
153
159
|
<Calendar
|
|
@@ -156,6 +162,7 @@ class DateLookup extends PureComponent<DateLookupPropsWithInputAttributes, DateL
|
|
|
156
162
|
max={max}
|
|
157
163
|
monthFormat={monthFormat}
|
|
158
164
|
placeholder={placeholder}
|
|
165
|
+
disabledRules={disabledRules}
|
|
159
166
|
onChange={this.handleDateChange}
|
|
160
167
|
onSelect={this.handleDateSelect}
|
|
161
168
|
/>
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Meta } from '@storybook/addon-docs/blocks';
|
|
2
|
+
|
|
3
|
+
<Meta title="Forms/DateLookup/Accessibility" />
|
|
4
|
+
|
|
5
|
+
# Accessibility
|
|
6
|
+
|
|
7
|
+
DateLookup wraps the Calendar component inside a dropdown panel. It provides full keyboard accessibility for opening, navigating, selecting, and dismissing dates.
|
|
8
|
+
|
|
9
|
+
## Keyboard Support
|
|
10
|
+
|
|
11
|
+
<table>
|
|
12
|
+
<thead>
|
|
13
|
+
<tr>
|
|
14
|
+
<th>Key</th>
|
|
15
|
+
<th>Context</th>
|
|
16
|
+
<th>Action</th>
|
|
17
|
+
</tr>
|
|
18
|
+
</thead>
|
|
19
|
+
<tbody>
|
|
20
|
+
<tr>
|
|
21
|
+
<td>
|
|
22
|
+
<code>ArrowLeft/Right/Up/Down</code>
|
|
23
|
+
</td>
|
|
24
|
+
<td>Trigger focused, panel closed</td>
|
|
25
|
+
<td>Opens the panel</td>
|
|
26
|
+
</tr>
|
|
27
|
+
<tr>
|
|
28
|
+
<td>
|
|
29
|
+
<code>ArrowLeft/Right/Up/Down</code>
|
|
30
|
+
</td>
|
|
31
|
+
<td>Panel open</td>
|
|
32
|
+
<td>Navigates between dates (see Calendar)</td>
|
|
33
|
+
</tr>
|
|
34
|
+
<tr>
|
|
35
|
+
<td>
|
|
36
|
+
<code>Enter</code> / <code>Space</code>
|
|
37
|
+
</td>
|
|
38
|
+
<td>Panel open, enabled date focused</td>
|
|
39
|
+
<td>Selects date, closes panel</td>
|
|
40
|
+
</tr>
|
|
41
|
+
<tr>
|
|
42
|
+
<td><code>Enter</code> / <code>Space</code></td>
|
|
43
|
+
<td>Panel open, disabled date focused</td>
|
|
44
|
+
<td>No action (panel stays open)</td>
|
|
45
|
+
</tr>
|
|
46
|
+
<tr>
|
|
47
|
+
<td>
|
|
48
|
+
<code>Escape</code>
|
|
49
|
+
</td>
|
|
50
|
+
<td>Panel open</td>
|
|
51
|
+
<td>Reverts to original value, closes panel</td>
|
|
52
|
+
</tr>
|
|
53
|
+
</tbody>
|
|
54
|
+
</table>
|
|
55
|
+
|
|
56
|
+
<br />
|
|
57
|
+
|
|
58
|
+
### Click Behaviour
|
|
59
|
+
|
|
60
|
+
- **Clicking an enabled date**: selects it, closes the panel, fires `onChange`
|
|
61
|
+
- **Clicking a disabled date**: does not select, panel stays open, focus remains in the calendar grid
|
|
62
|
+
|
|
63
|
+
## Screen Reader Announcements
|
|
64
|
+
|
|
65
|
+
DateLookup relies on the Calendar's built-in accessibility features for date announcements. See [Calendar
|
|
66
|
+
Accessibility](?path=/docs/forms-calendar-accessibility--docs) for details on how dates are labelled and described.
|
|
67
|
+
|
|
68
|
+
### Trigger Button
|
|
69
|
+
|
|
70
|
+
The trigger button announces the currently selected date (or placeholder text when no date is selected). It uses `aria-controls` to associate with the panel.
|
|
71
|
+
|
|
72
|
+
### Panel
|
|
73
|
+
|
|
74
|
+
The dropdown panel is rendered with `role="dialog"` and is connected to the trigger via `aria-controls`. When opened, focus moves into the calendar grid.
|
|
75
|
+
|
|
76
|
+
## Disabled Dates
|
|
77
|
+
|
|
78
|
+
When `disabledRules` is provided, disabled dates behave identically to the Calendar component:
|
|
79
|
+
|
|
80
|
+
- `aria-disabled="true"` marks the cell as unavailable
|
|
81
|
+
- "not available" is appended to the aria-label
|
|
82
|
+
- `aria-describedby` links to the InlinePrompt reason text
|
|
83
|
+
- Keyboard navigation reaches disabled dates (they are not skipped)
|
|
84
|
+
- Clicking or pressing Enter on a disabled date does **not** close the panel
|
|
85
|
+
|
|
86
|
+
### InlinePrompt
|
|
87
|
+
|
|
88
|
+
An InlinePrompt renders below the day grid inside the panel when the visible month contains disabled dates. It displays the configured `disabledRules.reason` text.
|
|
89
|
+
|
|
90
|
+
The prompt is linked to disabled cells via `aria-describedby`, so screen readers announce the reason when a disabled
|
|
91
|
+
date receives focus. See [Calendar Accessibility](?path=/docs/forms-calendar-accessibility--docs) for full details on
|
|
92
|
+
the prompt's announcement behaviour.
|
|
93
|
+
|
|
94
|
+
## Consumer Callbacks
|
|
95
|
+
|
|
96
|
+
The `onDateSelect` callback fires with `{ date, isEnabled }` whenever the user navigates to or clicks on any date. Consumers can use this to:
|
|
97
|
+
|
|
98
|
+
- Display a custom message explaining why a specific date is disabled
|
|
99
|
+
- Track which dates users attempt to select
|
|
100
|
+
- Provide alternative actions for disabled dates
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import { Size } from '../../common';
|
|
8
8
|
import DateLookup, { type DateLookupProps } from '..';
|
|
9
9
|
import Field from '../../Field';
|
|
10
|
+
import { storySourceWithoutNoise } from '../../../.storybook/helpers';
|
|
10
11
|
|
|
11
12
|
export default {
|
|
12
13
|
component: DateLookup,
|
|
@@ -34,7 +35,7 @@ thePast.setUTCDate(epoch.getUTCDate() - 10);
|
|
|
34
35
|
|
|
35
36
|
export const Sandbox = createSandboxStory({
|
|
36
37
|
code: `const App = () => {
|
|
37
|
-
const [value, setValue] = React.useState(new Date(
|
|
38
|
+
const [value, setValue] = React.useState(new Date(2024, 5, 14));
|
|
38
39
|
|
|
39
40
|
return (
|
|
40
41
|
<DateLookup
|
|
@@ -44,6 +45,11 @@ export const Sandbox = createSandboxStory({
|
|
|
44
45
|
placeholder="Choose date"
|
|
45
46
|
clearable
|
|
46
47
|
value={value}
|
|
48
|
+
disabledRules={{
|
|
49
|
+
weekdays: ['sat', 'sun'],
|
|
50
|
+
dates: [new Date(2024, 5, 19)],
|
|
51
|
+
reason: 'Weekends and holidays are unavailable.',
|
|
52
|
+
}}
|
|
47
53
|
onChange={(date) => setValue(date)}
|
|
48
54
|
/>
|
|
49
55
|
);
|
|
@@ -120,3 +126,55 @@ export const RightAligned: Story = {
|
|
|
120
126
|
);
|
|
121
127
|
},
|
|
122
128
|
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Use `disabledRules` to disable specific weekdays, individual dates, or even date ranges.
|
|
132
|
+
* For accessibility purposes, the `reason` field is required and renders as an InlinePrompt
|
|
133
|
+
* below the calendar grid.
|
|
134
|
+
*
|
|
135
|
+
* If you want to customise the `reason` on per-date basis, you can use `onDateSelect`.
|
|
136
|
+
*/
|
|
137
|
+
export const DisabledDates: Story = storySourceWithoutNoise({
|
|
138
|
+
args: {
|
|
139
|
+
size: Size.MEDIUM,
|
|
140
|
+
label: 'Select a date',
|
|
141
|
+
placeholder: 'Choose date',
|
|
142
|
+
monthFormat: 'long',
|
|
143
|
+
},
|
|
144
|
+
render: function Render(args: DateLookupProps) {
|
|
145
|
+
const DEFAULT_REASON = 'Some dates are unavailable for this service.';
|
|
146
|
+
const bankHolidays = [new Date(2025, 6, 14), new Date(2025, 7, 25)];
|
|
147
|
+
const [value, setValue] = useState<Date | null>(null);
|
|
148
|
+
const [reason, setReason] = useState(DEFAULT_REASON);
|
|
149
|
+
|
|
150
|
+
function determineReason(date: Date, isEnabled: boolean): string {
|
|
151
|
+
if (isEnabled) return DEFAULT_REASON;
|
|
152
|
+
if (bankHolidays.some((holiday) => holiday.toDateString() === date.toDateString())) {
|
|
153
|
+
return 'This date is a bank holiday.';
|
|
154
|
+
}
|
|
155
|
+
if (date.getDay() === 0 || date.getDay() === 6) {
|
|
156
|
+
return 'We do not operate on weekends.';
|
|
157
|
+
}
|
|
158
|
+
return 'This date is unavailable due to scheduled maintenance.';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<DateLookup
|
|
163
|
+
{...args}
|
|
164
|
+
min={new Date(2025, 6, 1)}
|
|
165
|
+
max={new Date(2025, 7, 31)}
|
|
166
|
+
value={value}
|
|
167
|
+
disabledRules={{
|
|
168
|
+
weekdays: ['sat', 'sun'],
|
|
169
|
+
dates: bankHolidays,
|
|
170
|
+
ranges: [{ start: new Date(2025, 6, 21), end: new Date(2025, 6, 25) }],
|
|
171
|
+
reason,
|
|
172
|
+
}}
|
|
173
|
+
onChange={(date) => setValue(date)}
|
|
174
|
+
onDateSelect={({ date, isEnabled }) => {
|
|
175
|
+
setReason(determineReason(date, isEnabled));
|
|
176
|
+
}}
|
|
177
|
+
/>
|
|
178
|
+
);
|
|
179
|
+
},
|
|
180
|
+
});
|
package/src/DateLookup/index.ts
CHANGED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { hasDisabledDatesInMonth } from './hasDisabledDatesInMonth';
|
|
2
|
+
import type { DisabledRules } from '../../../Calendar/Calendar.types';
|
|
3
|
+
|
|
4
|
+
describe('hasDisabledDatesInMonth', () => {
|
|
5
|
+
describe('weekdays', () => {
|
|
6
|
+
it('returns true when weekday rules exist (every month has all weekdays)', () => {
|
|
7
|
+
const rules: DisabledRules = { weekdays: ['sat', 'sun'], reason: 'Closed' };
|
|
8
|
+
expect(hasDisabledDatesInMonth(6, 2024, rules)).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('returns true for any month when weekday rules exist', () => {
|
|
12
|
+
const rules: DisabledRules = { weekdays: ['mon'], reason: 'Closed' };
|
|
13
|
+
expect(hasDisabledDatesInMonth(0, 2024, rules)).toBe(true);
|
|
14
|
+
expect(hasDisabledDatesInMonth(11, 2024, rules)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('returns false when weekdays array is empty', () => {
|
|
18
|
+
const rules: DisabledRules = { weekdays: [], reason: 'None' };
|
|
19
|
+
expect(hasDisabledDatesInMonth(6, 2024, rules)).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('specific dates', () => {
|
|
24
|
+
const rules: DisabledRules = {
|
|
25
|
+
dates: [new Date(2024, 6, 11), new Date(2024, 6, 25), new Date(2025, 0, 1)],
|
|
26
|
+
reason: 'Bank holidays',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
it('returns true when month contains a disabled date', () => {
|
|
30
|
+
expect(hasDisabledDatesInMonth(6, 2024, rules)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns false when month has no disabled dates', () => {
|
|
34
|
+
expect(hasDisabledDatesInMonth(5, 2024, rules)).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns true for another year with disabled date', () => {
|
|
38
|
+
expect(hasDisabledDatesInMonth(0, 2025, rules)).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('ranges', () => {
|
|
43
|
+
const rules: DisabledRules = {
|
|
44
|
+
ranges: [{ start: new Date(2024, 2, 29), end: new Date(2024, 3, 3) }],
|
|
45
|
+
reason: 'Maintenance',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
it('returns true when range starts in the month', () => {
|
|
49
|
+
expect(hasDisabledDatesInMonth(2, 2024, rules)).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns true when range ends in the month', () => {
|
|
53
|
+
expect(hasDisabledDatesInMonth(3, 2024, rules)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('returns true when range spans the entire month', () => {
|
|
57
|
+
const spanRules: DisabledRules = {
|
|
58
|
+
ranges: [{ start: new Date(2024, 0, 1), end: new Date(2024, 11, 31) }],
|
|
59
|
+
reason: 'Year-long',
|
|
60
|
+
};
|
|
61
|
+
expect(hasDisabledDatesInMonth(6, 2024, spanRules)).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns false when range does not overlap the month', () => {
|
|
65
|
+
expect(hasDisabledDatesInMonth(4, 2024, rules)).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('min/max constraints', () => {
|
|
70
|
+
it('returns false when all disabled dates in month are outside min/max', () => {
|
|
71
|
+
const rules: DisabledRules = {
|
|
72
|
+
dates: [new Date(2024, 6, 1), new Date(2024, 6, 2)],
|
|
73
|
+
reason: 'Early',
|
|
74
|
+
};
|
|
75
|
+
const min = new Date(2024, 6, 10);
|
|
76
|
+
const max = new Date(2024, 6, 31);
|
|
77
|
+
expect(hasDisabledDatesInMonth(6, 2024, rules, min, max)).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('returns true when weekdays exist regardless of min/max', () => {
|
|
81
|
+
const rules: DisabledRules = { weekdays: ['sat'], reason: 'Closed' };
|
|
82
|
+
const min = new Date(2024, 6, 10);
|
|
83
|
+
const max = new Date(2024, 6, 20);
|
|
84
|
+
expect(hasDisabledDatesInMonth(6, 2024, rules, min, max)).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('no rules', () => {
|
|
89
|
+
it('returns false when disabledRules is undefined', () => {
|
|
90
|
+
expect(hasDisabledDatesInMonth(6, 2024, undefined)).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns false when only reason is provided', () => {
|
|
94
|
+
const rules: DisabledRules = { reason: 'Reason only' };
|
|
95
|
+
expect(hasDisabledDatesInMonth(6, 2024, rules)).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
});
|