@dhis2-ui/calendar 10.18.0 → 10.19.1

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.
@@ -0,0 +1,25 @@
1
+ # CalendarInput
2
+
3
+ `CalendarInput` wraps the `Calendar` widget (`../calendar/`) with a text input, validation, and a clear button. Both get their date logic — parsing, localization, navigation — from `@dhis2/multi-calendar-dates`'s `useDatePicker`/`useResolvedDirection`/`validateDateString`; this directory has no date-math of its own.
4
+
5
+ ## Data flow
6
+
7
+ Dates cross the public API as calendar-native `YYYY-MM-DD` strings (in whatever `calendar` prop is set — not necessarily Gregorian ISO), never as `Temporal` objects. Treat any change that would leak a `Temporal`/date-engine object through `onDateSelect`, `date`, or similar props as a breaking change. `CalendarInput`'s `onDateSelect` payload also carries a `validation` object that `CalendarInput` computes itself via `validateDateString` — it is _not_ part of `multi-calendar-dates`'s types, so it's declared separately in `../../types/index.d.ts` (`CalendarInputValidation` / `CalendarInputOnDateSelectPayload`). If you change how validation is computed or shaped, update that file too.
8
+
9
+ ## Testing
10
+
11
+ Two surfaces, used for different things:
12
+
13
+ - **Jest** (`__tests__/calendar-input.test.js`, run via `yarn test`) — component behavior and logic: prop handling, validation, callbacks.
14
+ - **Cypress + Cucumber e2e** (`../features/*.feature` + step defs, driven off `../__e2e__/calendar-input.e2e.stories.js`, run via `yarn cy:test` at the repo root) — real rendered output and localization across calendars. Heavier; not run by `yarn test`.
15
+
16
+ When adding a prop or behavior, add a Jest test. If it's about rendering or localized text for a specific calendar/locale, check whether a `.feature` scenario should exist too.
17
+
18
+ Coverage decisions in this component are scoped to the three calendars the project actually supports in practice: **Gregorian, Ethiopic, Nepali**. Ethiopic and Nepali behave differently enough from Gregorian (Ethiopic is a native-ICU calendar with its own era handling; Nepali is a custom, non-ICU calendar implemented in `multi-calendar-dates` via a lookup table, not `Intl`) that "tested for Gregorian" should not be assumed to mean "tested for the other two" — verify explicitly rather than assuming parity.
19
+
20
+ ## Gotchas
21
+
22
+ - **Day-cell tests must assert on visible text, not just the `data-test` attribute.** `data-test` on a day cell is always the full date string (e.g. `2024-10-17`); the _rendered label_ is a separate value from `multi-calendar-dates` and can silently regress into something else (it has, historically) without any `data-test`-based assertion catching it. Assert `within(cell).getByText('17')` (or the calendar-appropriate bare day number) in addition to clicking by `data-test`.
23
+ - **`useDatePicker` recomputes on every render of `CalendarInput`, regardless of whether the calendar dropdown is open.** Its output must stay properly memoized inside `multi-calendar-dates` — if you find yourself adding expensive work anywhere in this render path (here or upstream), verify it doesn't run on every keystroke. The `performance (LIBS-763)` describe block in the test file guards against this regressing again; keep it green.
24
+ - **`inputWidth` is deliberately untested.** It's a passthrough to a `styled-jsx` CSS width — purely cosmetic, and `styled-jsx` output is fragile to assert on in jsdom. Don't assume this is an oversight.
25
+ - **`minDate`/`maxDate`/`format`/`strictValidation` get passed to two different functions with different expectations**: `validateDateString` (via `validationOptions`, keyed `minDateString`/`maxDateString`) drives the visible error text, while `useDatePicker` takes the same values under its own key names (`minDate`/`maxDate`) to decide whether to fall back to today internally in strict mode. These used to silently disagree (the `validationOptions` object, keyed for `validateDateString`, was spread straight into `useDatePicker` too) — the widget would keep an out-of-range date selected in strict mode even though the input correctly rejected it. Pass `useDatePicker` its own correctly-named fields explicitly; don't assume an options object built for one of these functions is safe to reuse for the other.
@@ -31,6 +31,62 @@ describe('Calendar Input', () => {
31
31
  }));
32
32
  jest.useRealTimers();
33
33
  });
34
+ it('allow selection of a date through the calendar widget (ethiopic)', async () => {
35
+ jest.useFakeTimers();
36
+ jest.setSystemTime(new Date('2024-10-22T09:05:00.000Z'));
37
+ const onDateSelectMock = jest.fn();
38
+ const screen = (0, _react.render)(/*#__PURE__*/_react2.default.createElement(_calendarInput.CalendarInput, {
39
+ calendar: "ethiopian",
40
+ onDateSelect: onDateSelectMock
41
+ }));
42
+ const dateInput = (0, _react.within)(screen.getByTestId('dhis2-uicore-input')).getByRole('textbox');
43
+ _react.fireEvent.focus(dateInput);
44
+ const calendar = await screen.findByTestId('calendar');
45
+ expect(calendar).toBeInTheDocument();
46
+ const todayString = '2017-02-12';
47
+ const today = (0, _react.within)(calendar).getByTestId(todayString);
48
+
49
+ // the visible day-cell text must be the bare day number, not the
50
+ // full date string used for data-test - regressed silently during
51
+ // the multi-calendar-dates 2.2.0-alpha.1 upgrade (see CLAUDE.md)
52
+ expect((0, _react.within)(today).getByText('12')).toBeInTheDocument();
53
+ _react.fireEvent.click(today);
54
+ await (0, _react.waitFor)(() => {
55
+ expect(calendar).not.toBeInTheDocument();
56
+ });
57
+ expect(onDateSelectMock).toHaveBeenCalledWith(expect.objectContaining({
58
+ calendarDateString: todayString
59
+ }));
60
+ jest.useRealTimers();
61
+ });
62
+ it('allow selection of a date through the calendar widget (nepali)', async () => {
63
+ jest.useFakeTimers();
64
+ jest.setSystemTime(new Date('2024-10-22T09:05:00.000Z'));
65
+ const onDateSelectMock = jest.fn();
66
+ const screen = (0, _react.render)(/*#__PURE__*/_react2.default.createElement(_calendarInput.CalendarInput, {
67
+ calendar: "nepali",
68
+ onDateSelect: onDateSelectMock
69
+ }));
70
+ const dateInput = (0, _react.within)(screen.getByTestId('dhis2-uicore-input')).getByRole('textbox');
71
+ _react.fireEvent.focus(dateInput);
72
+ const calendar = await screen.findByTestId('calendar');
73
+ expect(calendar).toBeInTheDocument();
74
+ const todayString = '2081-07-06';
75
+ const today = (0, _react.within)(calendar).getByTestId(todayString);
76
+
77
+ // the visible day-cell text must be the bare day number, not the
78
+ // full date string used for data-test - regressed silently during
79
+ // the multi-calendar-dates 2.2.0-alpha.1 upgrade (see CLAUDE.md)
80
+ expect((0, _react.within)(today).getByText('6')).toBeInTheDocument();
81
+ _react.fireEvent.click(today);
82
+ await (0, _react.waitFor)(() => {
83
+ expect(calendar).not.toBeInTheDocument();
84
+ });
85
+ expect(onDateSelectMock).toHaveBeenCalledWith(expect.objectContaining({
86
+ calendarDateString: todayString
87
+ }));
88
+ jest.useRealTimers();
89
+ });
34
90
  it('allow selection of a date through the input', async () => {
35
91
  const onDateSelectMock = jest.fn();
36
92
  const screen = (0, _react.render)(/*#__PURE__*/_react2.default.createElement(_calendarInput.CalendarInput, {
@@ -49,7 +105,215 @@ describe('Calendar Input', () => {
49
105
  calendarDateString: dateInputString
50
106
  }));
51
107
  });
108
+ it('rejects input whose detected shape does not match the format prop', () => {
109
+ // "2024-12-25" is unambiguously YYYY-MM-DD shaped (4-digit group
110
+ // first). With format="DD-MM-YYYY" that mismatch is rejected
111
+ // rather than reinterpreted.
112
+ const screen = (0, _react.render)(/*#__PURE__*/_react2.default.createElement(CalendarWithValidation, {
113
+ calendar: "gregory",
114
+ format: "DD-MM-YYYY"
115
+ }));
116
+ const dateInput = (0, _react.within)(screen.getByTestId('dhis2-uicore-input')).getByRole('textbox');
117
+ _react.fireEvent.change(dateInput, {
118
+ target: {
119
+ value: '2024-12-25'
120
+ }
121
+ });
122
+ _react.fireEvent.blur(dateInput);
123
+ expect(screen.getByText('Date string format does not match the specified format. Expected DD-MM-YYYY but got YYYY-MM-DD.')).toBeInTheDocument();
124
+ });
125
+ it('accepts the same typed input without the format prop', () => {
126
+ const screen = (0, _react.render)(/*#__PURE__*/_react2.default.createElement(CalendarWithValidation, {
127
+ calendar: "gregory"
128
+ }));
129
+ const dateInput = (0, _react.within)(screen.getByTestId('dhis2-uicore-input')).getByRole('textbox');
130
+ _react.fireEvent.change(dateInput, {
131
+ target: {
132
+ value: '2024-12-25'
133
+ }
134
+ });
135
+ _react.fireEvent.blur(dateInput);
136
+ expect(screen.queryByText(/does not match the specified format/)).not.toBeInTheDocument();
137
+ });
138
+ it('limits the year selector to the current year when pastOnly is set', async () => {
139
+ jest.useFakeTimers();
140
+ jest.setSystemTime(new Date('2024-10-22T09:05:00.000Z'));
141
+ const screen = (0, _react.render)(/*#__PURE__*/_react2.default.createElement(_calendarInput.CalendarInput, {
142
+ calendar: "gregory",
143
+ pastOnly: true,
144
+ onDateSelect: jest.fn()
145
+ }));
146
+ const dateInput = (0, _react.within)(screen.getByTestId('dhis2-uicore-input')).getByRole('textbox');
147
+ _react.fireEvent.focus(dateInput);
148
+ const yearSelect = await screen.findByTestId('calendar-year-select');
149
+ const years = (0, _react.within)(yearSelect).getAllByRole('option').map(option => Number(option.value));
150
+ expect(Math.max(...years)).toBe(2024);
151
+ jest.useRealTimers();
152
+ });
153
+ it('includes future years in the year selector without pastOnly', async () => {
154
+ jest.useFakeTimers();
155
+ jest.setSystemTime(new Date('2024-10-22T09:05:00.000Z'));
156
+ const screen = (0, _react.render)(/*#__PURE__*/_react2.default.createElement(_calendarInput.CalendarInput, {
157
+ calendar: "gregory",
158
+ onDateSelect: jest.fn()
159
+ }));
160
+ const dateInput = (0, _react.within)(screen.getByTestId('dhis2-uicore-input')).getByRole('textbox');
161
+ _react.fireEvent.focus(dateInput);
162
+ const yearSelect = await screen.findByTestId('calendar-year-select');
163
+ const years = (0, _react.within)(yearSelect).getAllByRole('option').map(option => Number(option.value));
164
+ expect(Math.max(...years)).toBeGreaterThan(2024);
165
+ jest.useRealTimers();
166
+ });
167
+ it('sets the calendar wrapper direction from the dir prop', async () => {
168
+ const screen = (0, _react.render)(/*#__PURE__*/_react2.default.createElement(_calendarInput.CalendarInput, {
169
+ calendar: "gregory",
170
+ dir: "rtl",
171
+ onDateSelect: jest.fn()
172
+ }));
173
+ const dateInput = (0, _react.within)(screen.getByTestId('dhis2-uicore-input')).getByRole('textbox');
174
+ _react.fireEvent.focus(dateInput);
175
+ const calendar = await screen.findByTestId('calendar');
176
+ expect(calendar).toHaveAttribute('dir', 'rtl');
177
+ });
178
+ it('renders day labels using the numberingSystem prop', async () => {
179
+ jest.useFakeTimers();
180
+ jest.setSystemTime(new Date('2024-10-22T09:05:00.000Z'));
181
+ const screen = (0, _react.render)(/*#__PURE__*/_react2.default.createElement(_calendarInput.CalendarInput, {
182
+ calendar: "gregory",
183
+ numberingSystem: "arab",
184
+ onDateSelect: jest.fn()
185
+ }));
186
+ const dateInput = (0, _react.within)(screen.getByTestId('dhis2-uicore-input')).getByRole('textbox');
187
+ _react.fireEvent.focus(dateInput);
188
+ const calendar = await screen.findByTestId('calendar');
189
+ const today = (0, _react.within)(calendar).getByTestId('2024-10-22');
190
+ expect(today).toHaveTextContent('٢٢');
191
+ jest.useRealTimers();
192
+ });
193
+ it('renders weekday headers using the weekDayFormat prop', async () => {
194
+ const screen = (0, _react.render)(/*#__PURE__*/_react2.default.createElement(_calendarInput.CalendarInput, {
195
+ calendar: "gregory",
196
+ weekDayFormat: "long",
197
+ onDateSelect: jest.fn()
198
+ }));
199
+ const dateInput = (0, _react.within)(screen.getByTestId('dhis2-uicore-input')).getByRole('textbox');
200
+ _react.fireEvent.focus(dateInput);
201
+ const calendar = await screen.findByTestId('calendar');
202
+ expect((0, _react.within)(calendar).getByText('Monday')).toBeInTheDocument();
203
+ });
204
+
205
+ // Regression coverage for LIBS-763: useDatePicker (in @dhis2/multi-calendar-dates)
206
+ // used to rebuild the year/month navigation lists and the day-cell grid on every
207
+ // render - including every keystroke while typing here, with the calendar widget
208
+ // open - regardless of whether the visible month/calendar/locale had actually
209
+ // changed. Fixed upstream in @dhis2/multi-calendar-dates@3.0.0-alpha.1.
210
+ describe('performance (LIBS-763)', () => {
211
+ // For gregory/ethiopic (native-ICU calendars) the broken memoization showed
212
+ // up as ~500 redundant `new Intl.DateTimeFormat(...)` constructions for a
213
+ // single keystroke; count is a deterministic, machine-speed-independent
214
+ // signal, unlike timing it directly.
215
+ const countDateTimeFormatConstructions = fn => {
216
+ const realResolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions;
217
+ let count = 0;
218
+ Intl.DateTimeFormat.prototype.resolvedOptions = function (...args) {
219
+ count++;
220
+ return realResolvedOptions.apply(this, args);
221
+ };
222
+ try {
223
+ fn();
224
+ } finally {
225
+ Intl.DateTimeFormat.prototype.resolvedOptions = realResolvedOptions;
226
+ }
227
+ return count;
228
+ };
229
+ it('does not reconstruct Intl.DateTimeFormat instances while typing (gregory)', async () => {
230
+ const screen = (0, _react.render)(/*#__PURE__*/_react2.default.createElement(_calendarInput.CalendarInput, {
231
+ calendar: "gregory",
232
+ onDateSelect: jest.fn()
233
+ }));
234
+ const dateInput = (0, _react.within)(screen.getByTestId('dhis2-uicore-input')).getByRole('textbox');
235
+ _react.fireEvent.focus(dateInput);
236
+ await screen.findByTestId('calendar');
237
+ const constructions = countDateTimeFormatConstructions(() => {
238
+ _react.fireEvent.change(dateInput, {
239
+ target: {
240
+ value: '2024-10-1'
241
+ }
242
+ });
243
+ });
244
+ expect(constructions).toBeLessThan(10);
245
+ });
246
+ it('does not reconstruct Intl.DateTimeFormat instances while typing (ethiopic)', async () => {
247
+ const screen = (0, _react.render)(/*#__PURE__*/_react2.default.createElement(_calendarInput.CalendarInput, {
248
+ calendar: "ethiopian",
249
+ onDateSelect: jest.fn()
250
+ }));
251
+ const dateInput = (0, _react.within)(screen.getByTestId('dhis2-uicore-input')).getByRole('textbox');
252
+ _react.fireEvent.focus(dateInput);
253
+ await screen.findByTestId('calendar');
254
+ const constructions = countDateTimeFormatConstructions(() => {
255
+ _react.fireEvent.change(dateInput, {
256
+ target: {
257
+ value: '2017-02-0'
258
+ }
259
+ });
260
+ });
261
+ expect(constructions).toBeLessThan(10);
262
+ });
263
+
264
+ // Nepali is a custom (non-ICU) calendar, so it never goes through
265
+ // Intl.DateTimeFormat - the same broken memoization instead showed up as
266
+ // redundant custom-calendar date-conversion work, so time it directly.
267
+ // 300ms for 10 keystrokes is ~25x the fixed baseline (~13ms) and ~2.5x
268
+ // below the broken one (~760ms), leaving headroom in both directions.
269
+ // Skipped in CI: unlike the count-based tests above, this asserts on
270
+ // wall-clock time, which can be flaky on slower/shared CI runners.
271
+ // Uncomment to run locally when touching useDatePicker/useNavigation.
272
+ it.skip('keeps typing fast (nepali)', async () => {
273
+ const screen = (0, _react.render)(/*#__PURE__*/_react2.default.createElement(_calendarInput.CalendarInput, {
274
+ calendar: "nepali",
275
+ onDateSelect: jest.fn()
276
+ }));
277
+ const dateInput = (0, _react.within)(screen.getByTestId('dhis2-uicore-input')).getByRole('textbox');
278
+ _react.fireEvent.focus(dateInput);
279
+ await screen.findByTestId('calendar');
280
+ const chars = '2081-07-01'.split('');
281
+ const progressive = chars.map((_, i) => chars.slice(0, i + 1).join(''));
282
+ const start = process.hrtime.bigint();
283
+ progressive.forEach(value => _react.fireEvent.change(dateInput, {
284
+ target: {
285
+ value
286
+ }
287
+ }));
288
+ const elapsedMs = Number(process.hrtime.bigint() - start) / 1e6;
289
+ expect(elapsedMs).toBeLessThan(300);
290
+ });
291
+ });
52
292
  describe('validation', () => {
293
+ it('should show a warning instead of an error when strictValidation is false', () => {
294
+ const onDateSelectMock = jest.fn();
295
+ const screen = (0, _react.render)(/*#__PURE__*/_react2.default.createElement(CalendarWithValidation, {
296
+ calendar: "gregory",
297
+ minDate: "2024-01-01",
298
+ strictValidation: false,
299
+ onDateSelect: onDateSelectMock
300
+ }));
301
+ const dateInputString = '2023-10-12';
302
+ const dateInput = (0, _react.within)(screen.getByTestId('dhis2-uicore-input')).getByRole('textbox');
303
+ _react.fireEvent.change(dateInput, {
304
+ target: {
305
+ value: dateInputString
306
+ }
307
+ });
308
+ _react.fireEvent.blur(dateInput);
309
+ expect(screen.getByText('Date 2023-10-12 is less than the minimum allowed date 2024-01-01.')).toBeInTheDocument();
310
+ expect(onDateSelectMock).toHaveBeenCalledWith(expect.objectContaining({
311
+ validation: expect.objectContaining({
312
+ warning: true,
313
+ valid: true
314
+ })
315
+ }));
316
+ });
53
317
  it('should validate minimum date', () => {
54
318
  const onDateSelectMock = jest.fn();
55
319
  const screen = (0, _react.render)(/*#__PURE__*/_react2.default.createElement(CalendarWithValidation, {
@@ -68,6 +332,60 @@ describe('Calendar Input', () => {
68
332
  expect(screen.getByText('Date 2023-10-12 is less than the minimum allowed date 2024-01-01.')).toBeInTheDocument();
69
333
  expect(onDateSelectMock).toHaveBeenCalledTimes(1);
70
334
  });
335
+
336
+ // Regression test: minDate/maxDate used to reach validateDateString
337
+ // (as minDateString/maxDateString) but not useDatePicker itself, due to a
338
+ // key mismatch - so in strict mode the input rejected an out-of-range
339
+ // date while the widget still selected it. Warning mode is unaffected
340
+ // (see the test below) since useDatePicker only falls back to today when
341
+ // strictValidation produces an `error`.
342
+ it('calendar widget falls back to today for a date the input has flagged as below the minimum (strict mode)', async () => {
343
+ jest.useFakeTimers();
344
+ jest.setSystemTime(new Date('2024-10-22T09:05:00.000Z'));
345
+ const screen = (0, _react.render)(/*#__PURE__*/_react2.default.createElement(CalendarWithValidation, {
346
+ calendar: "gregory",
347
+ minDate: "2024-01-01"
348
+ }));
349
+ const dateInput = (0, _react.within)(screen.getByTestId('dhis2-uicore-input')).getByRole('textbox');
350
+ _react.fireEvent.change(dateInput, {
351
+ target: {
352
+ value: '2020-01-15'
353
+ }
354
+ });
355
+ _react.fireEvent.blur(dateInput);
356
+ expect(screen.getByText('Date 2020-01-15 is less than the minimum allowed date 2024-01-01.')).toBeInTheDocument();
357
+ _react.fireEvent.focus(dateInput);
358
+ const calendar = await screen.findByTestId('calendar');
359
+
360
+ // expected: the widget falls back to today, like useDatePicker's own
361
+ // getInvalidDateResult does for a date it recognizes as invalid
362
+ expect((0, _react.within)(calendar).getByTestId('2024-10-22').querySelector('button')).toHaveClass('isToday');
363
+ jest.useRealTimers();
364
+ });
365
+ it('calendar widget correctly still selects an out-of-range date in warning mode', async () => {
366
+ jest.useFakeTimers();
367
+ jest.setSystemTime(new Date('2024-10-22T09:05:00.000Z'));
368
+ const screen = (0, _react.render)(/*#__PURE__*/_react2.default.createElement(CalendarWithValidation, {
369
+ calendar: "gregory",
370
+ minDate: "2024-01-01",
371
+ strictValidation: false
372
+ }));
373
+ const dateInput = (0, _react.within)(screen.getByTestId('dhis2-uicore-input')).getByRole('textbox');
374
+ _react.fireEvent.change(dateInput, {
375
+ target: {
376
+ value: '2020-01-15'
377
+ }
378
+ });
379
+ _react.fireEvent.blur(dateInput);
380
+ expect(screen.getByText('Date 2020-01-15 is less than the minimum allowed date 2024-01-01.')).toBeInTheDocument();
381
+ _react.fireEvent.focus(dateInput);
382
+ const calendar = await screen.findByTestId('calendar');
383
+
384
+ // warning mode means "flag it, but still let them pick it" - the
385
+ // widget showing the out-of-range date as selected is intentional
386
+ expect((0, _react.within)(calendar).getByTestId('2020-01-15').querySelector('button')).toHaveClass('isSelected');
387
+ jest.useRealTimers();
388
+ });
71
389
  it('should validate maximum date', () => {
72
390
  const {
73
391
  getByTestId,
@@ -86,8 +404,7 @@ describe('Calendar Input', () => {
86
404
  _react.fireEvent.blur(dateInput);
87
405
  expect(getByText('Date 2024-10-12 is greater than the maximum allowed date 2024-01-01.')).toBeInTheDocument();
88
406
  });
89
- // Temporarily disabled
90
- it.skip('should validate date in ethiopic calendar', () => {
407
+ it('should validate date in ethiopic calendar', () => {
91
408
  const onDateSelectMock = jest.fn();
92
409
  const {
93
410
  getByTestId,
@@ -125,7 +442,6 @@ describe('Calendar Input', () => {
125
442
  _react.fireEvent.blur(dateInput);
126
443
  expect(getByText('Invalid date in specified calendar')).toBeInTheDocument();
127
444
  });
128
- // ToDo: these scenarios seem to work but they timeout on CI sporadically - ticket: https://dhis2.atlassian.net/browse/LIBS-763
129
445
  it('should validate date in nepali calendar', () => {
130
446
  const onDateSelectMock = jest.fn();
131
447
  const {
@@ -86,7 +86,10 @@ const CalendarInput = ({
86
86
  setOpen(false);
87
87
  },
88
88
  date,
89
- ...validationOptions,
89
+ minDate,
90
+ maxDate,
91
+ format,
92
+ strictValidation,
90
93
  options: useDatePickerOptions
91
94
  });
92
95
  const handleChange = e => {
@@ -0,0 +1,25 @@
1
+ # CalendarInput
2
+
3
+ `CalendarInput` wraps the `Calendar` widget (`../calendar/`) with a text input, validation, and a clear button. Both get their date logic — parsing, localization, navigation — from `@dhis2/multi-calendar-dates`'s `useDatePicker`/`useResolvedDirection`/`validateDateString`; this directory has no date-math of its own.
4
+
5
+ ## Data flow
6
+
7
+ Dates cross the public API as calendar-native `YYYY-MM-DD` strings (in whatever `calendar` prop is set — not necessarily Gregorian ISO), never as `Temporal` objects. Treat any change that would leak a `Temporal`/date-engine object through `onDateSelect`, `date`, or similar props as a breaking change. `CalendarInput`'s `onDateSelect` payload also carries a `validation` object that `CalendarInput` computes itself via `validateDateString` — it is _not_ part of `multi-calendar-dates`'s types, so it's declared separately in `../../types/index.d.ts` (`CalendarInputValidation` / `CalendarInputOnDateSelectPayload`). If you change how validation is computed or shaped, update that file too.
8
+
9
+ ## Testing
10
+
11
+ Two surfaces, used for different things:
12
+
13
+ - **Jest** (`__tests__/calendar-input.test.js`, run via `yarn test`) — component behavior and logic: prop handling, validation, callbacks.
14
+ - **Cypress + Cucumber e2e** (`../features/*.feature` + step defs, driven off `../__e2e__/calendar-input.e2e.stories.js`, run via `yarn cy:test` at the repo root) — real rendered output and localization across calendars. Heavier; not run by `yarn test`.
15
+
16
+ When adding a prop or behavior, add a Jest test. If it's about rendering or localized text for a specific calendar/locale, check whether a `.feature` scenario should exist too.
17
+
18
+ Coverage decisions in this component are scoped to the three calendars the project actually supports in practice: **Gregorian, Ethiopic, Nepali**. Ethiopic and Nepali behave differently enough from Gregorian (Ethiopic is a native-ICU calendar with its own era handling; Nepali is a custom, non-ICU calendar implemented in `multi-calendar-dates` via a lookup table, not `Intl`) that "tested for Gregorian" should not be assumed to mean "tested for the other two" — verify explicitly rather than assuming parity.
19
+
20
+ ## Gotchas
21
+
22
+ - **Day-cell tests must assert on visible text, not just the `data-test` attribute.** `data-test` on a day cell is always the full date string (e.g. `2024-10-17`); the _rendered label_ is a separate value from `multi-calendar-dates` and can silently regress into something else (it has, historically) without any `data-test`-based assertion catching it. Assert `within(cell).getByText('17')` (or the calendar-appropriate bare day number) in addition to clicking by `data-test`.
23
+ - **`useDatePicker` recomputes on every render of `CalendarInput`, regardless of whether the calendar dropdown is open.** Its output must stay properly memoized inside `multi-calendar-dates` — if you find yourself adding expensive work anywhere in this render path (here or upstream), verify it doesn't run on every keystroke. The `performance (LIBS-763)` describe block in the test file guards against this regressing again; keep it green.
24
+ - **`inputWidth` is deliberately untested.** It's a passthrough to a `styled-jsx` CSS width — purely cosmetic, and `styled-jsx` output is fragile to assert on in jsdom. Don't assume this is an oversight.
25
+ - **`minDate`/`maxDate`/`format`/`strictValidation` get passed to two different functions with different expectations**: `validateDateString` (via `validationOptions`, keyed `minDateString`/`maxDateString`) drives the visible error text, while `useDatePicker` takes the same values under its own key names (`minDate`/`maxDate`) to decide whether to fall back to today internally in strict mode. These used to silently disagree (the `validationOptions` object, keyed for `validateDateString`, was spread straight into `useDatePicker` too) — the widget would keep an out-of-range date selected in strict mode even though the input correctly rejected it. Pass `useDatePicker` its own correctly-named fields explicitly; don't assume an options object built for one of these functions is safe to reuse for the other.