@arbor-education/design-system.components 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/release.yml +1 -1
- package/CHANGELOG.md +10 -0
- package/dist/components/button/Button.d.ts.map +1 -1
- package/dist/components/button/Button.js +2 -2
- package/dist/components/button/Button.js.map +1 -1
- package/dist/components/combobox/Combobox.d.ts.map +1 -1
- package/dist/components/combobox/Combobox.js +2 -1
- package/dist/components/combobox/Combobox.js.map +1 -1
- package/dist/components/combobox/Combobox.test.js +98 -61
- package/dist/components/combobox/Combobox.test.js.map +1 -1
- package/dist/components/combobox/useComboboxPopoverBehavior.d.ts +3 -1
- package/dist/components/combobox/useComboboxPopoverBehavior.d.ts.map +1 -1
- package/dist/components/combobox/useComboboxPopoverBehavior.js +7 -6
- package/dist/components/combobox/useComboboxPopoverBehavior.js.map +1 -1
- package/dist/components/combobox/useComboboxState.d.ts.map +1 -1
- package/dist/components/combobox/useComboboxState.js +4 -1
- package/dist/components/combobox/useComboboxState.js.map +1 -1
- package/dist/components/datePicker/DatePicker.d.ts +4 -1
- package/dist/components/datePicker/DatePicker.d.ts.map +1 -1
- package/dist/components/datePicker/DatePicker.js +77 -37
- package/dist/components/datePicker/DatePicker.js.map +1 -1
- package/dist/components/datePicker/DatePicker.stories.d.ts +28 -3
- package/dist/components/datePicker/DatePicker.stories.d.ts.map +1 -1
- package/dist/components/datePicker/DatePicker.stories.js +62 -9
- package/dist/components/datePicker/DatePicker.stories.js.map +1 -1
- package/dist/components/datePicker/DatePicker.test.js +133 -66
- package/dist/components/datePicker/DatePicker.test.js.map +1 -1
- package/dist/components/datePicker/DatePickerCalendarHeader.d.ts +8 -0
- package/dist/components/datePicker/DatePickerCalendarHeader.d.ts.map +1 -0
- package/dist/components/datePicker/DatePickerCalendarHeader.js +36 -0
- package/dist/components/datePicker/DatePickerCalendarHeader.js.map +1 -0
- package/dist/components/datePicker/dateInputUtils.d.ts +25 -0
- package/dist/components/datePicker/dateInputUtils.d.ts.map +1 -0
- package/dist/components/datePicker/dateInputUtils.js +60 -0
- package/dist/components/datePicker/dateInputUtils.js.map +1 -0
- package/dist/components/datePicker/datePickerTestUtils.test-helpers.d.ts +2 -0
- package/dist/components/datePicker/datePickerTestUtils.test-helpers.d.ts.map +1 -0
- package/dist/components/datePicker/datePickerTestUtils.test-helpers.js +4 -0
- package/dist/components/datePicker/datePickerTestUtils.test-helpers.js.map +1 -0
- package/dist/components/dateTimePicker/DateTimePicker.d.ts +22 -0
- package/dist/components/dateTimePicker/DateTimePicker.d.ts.map +1 -0
- package/dist/components/dateTimePicker/DateTimePicker.js +132 -0
- package/dist/components/dateTimePicker/DateTimePicker.js.map +1 -0
- package/dist/components/dateTimePicker/DateTimePicker.stories.d.ts +77 -0
- package/dist/components/dateTimePicker/DateTimePicker.stories.d.ts.map +1 -0
- package/dist/components/dateTimePicker/DateTimePicker.stories.js +163 -0
- package/dist/components/dateTimePicker/DateTimePicker.stories.js.map +1 -0
- package/dist/components/dateTimePicker/DateTimePicker.test.d.ts +2 -0
- package/dist/components/dateTimePicker/DateTimePicker.test.d.ts.map +1 -0
- package/dist/components/dateTimePicker/DateTimePicker.test.js +235 -0
- package/dist/components/dateTimePicker/DateTimePicker.test.js.map +1 -0
- package/dist/components/formField/FormField.test.d.ts.map +1 -1
- package/dist/components/formField/FormField.test.js +5 -5
- package/dist/components/formField/FormField.test.js.map +1 -1
- package/dist/components/formField/inputs/selectDropdown/SelectDropdown.d.ts +1 -0
- package/dist/components/formField/inputs/selectDropdown/SelectDropdown.d.ts.map +1 -1
- package/dist/components/formField/inputs/selectDropdown/SelectDropdown.js +7 -3
- package/dist/components/formField/inputs/selectDropdown/SelectDropdown.js.map +1 -1
- package/dist/components/formField/inputs/selectDropdown/SelectDropdown.test.js +12 -0
- package/dist/components/formField/inputs/selectDropdown/SelectDropdown.test.js.map +1 -1
- package/dist/components/formField/inputs/text/TextInput.d.ts +4 -1
- package/dist/components/formField/inputs/text/TextInput.d.ts.map +1 -1
- package/dist/components/formField/inputs/text/TextInput.js +5 -4
- package/dist/components/formField/inputs/text/TextInput.js.map +1 -1
- package/dist/components/formField/inputs/text/TextInput.stories.d.ts +4 -1
- package/dist/components/formField/inputs/text/TextInput.stories.d.ts.map +1 -1
- package/dist/components/table/Table.d.ts.map +1 -1
- package/dist/components/table/Table.js +2 -0
- package/dist/components/table/Table.js.map +1 -1
- package/dist/components/table/Table.stories.d.ts +1 -0
- package/dist/components/table/Table.stories.d.ts.map +1 -1
- package/dist/components/table/Table.stories.js +37 -0
- package/dist/components/table/Table.stories.js.map +1 -1
- package/dist/components/table/cellRenderers/BooleanCellRenderer.d.ts +3 -0
- package/dist/components/table/cellRenderers/BooleanCellRenderer.d.ts.map +1 -0
- package/dist/components/table/cellRenderers/BooleanCellRenderer.js +15 -0
- package/dist/components/table/cellRenderers/BooleanCellRenderer.js.map +1 -0
- package/dist/components/table/cellRenderers/BooleanCellRenderer.test.d.ts +2 -0
- package/dist/components/table/cellRenderers/BooleanCellRenderer.test.d.ts.map +1 -0
- package/dist/components/table/cellRenderers/BooleanCellRenderer.test.js +31 -0
- package/dist/components/table/cellRenderers/BooleanCellRenderer.test.js.map +1 -0
- package/dist/index.css +258 -3
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/button/Button.tsx +2 -1
- package/src/components/combobox/Combobox.test.tsx +104 -61
- package/src/components/combobox/Combobox.tsx +3 -1
- package/src/components/combobox/useComboboxPopoverBehavior.ts +10 -5
- package/src/components/combobox/useComboboxState.ts +4 -1
- package/src/components/datePicker/DatePicker.stories.tsx +67 -9
- package/src/components/datePicker/DatePicker.test.tsx +157 -72
- package/src/components/datePicker/DatePicker.tsx +163 -69
- package/src/components/datePicker/DatePickerCalendarHeader.tsx +82 -0
- package/src/components/datePicker/date-field-hint.scss +152 -0
- package/src/components/datePicker/dateInputUtils.ts +117 -0
- package/src/components/datePicker/datePicker.scss +53 -29
- package/src/components/datePicker/datePickerTestUtils.test-helpers.ts +6 -0
- package/src/components/dateTimePicker/DateTimePicker.stories.tsx +202 -0
- package/src/components/dateTimePicker/DateTimePicker.test.tsx +295 -0
- package/src/components/dateTimePicker/DateTimePicker.tsx +293 -0
- package/src/components/dateTimePicker/dateTimePicker.scss +17 -0
- package/src/components/formField/FormField.test.tsx +5 -5
- package/src/components/formField/inputs/selectDropdown/SelectDropdown.test.tsx +28 -0
- package/src/components/formField/inputs/selectDropdown/SelectDropdown.tsx +8 -2
- package/src/components/formField/inputs/text/TextInput.tsx +6 -3
- package/src/components/table/Table.stories.tsx +48 -0
- package/src/components/table/Table.tsx +2 -0
- package/src/components/table/cellRenderers/BooleanCellRenderer.test.tsx +37 -0
- package/src/components/table/cellRenderers/BooleanCellRenderer.tsx +34 -0
- package/src/components/table/cellRenderers/booleanCellRenderer.scss +7 -0
- package/src/index.scss +2 -0
- package/src/index.ts +3 -0
|
@@ -2,18 +2,22 @@ import { describe, expect, test, vi } from 'vitest';
|
|
|
2
2
|
import { render, screen, within, fireEvent } from '@testing-library/react';
|
|
3
3
|
import userEvent from '@testing-library/user-event';
|
|
4
4
|
import '@testing-library/jest-dom/vitest';
|
|
5
|
-
import { format } from 'date-fns';
|
|
6
5
|
import { DatePicker } from './DatePicker';
|
|
6
|
+
import { formatNativeDateInputValue } from './dateInputUtils';
|
|
7
|
+
import { getDropdownTrigger } from './datePickerTestUtils.test-helpers';
|
|
8
|
+
|
|
9
|
+
const getDateField = (container: HTMLElement) => (
|
|
10
|
+
container.querySelector('input[type="date"]') as HTMLInputElement
|
|
11
|
+
);
|
|
7
12
|
|
|
8
13
|
describe('DatePicker', () => {
|
|
9
|
-
test('renders a
|
|
10
|
-
render(<DatePicker />);
|
|
11
|
-
expect(
|
|
14
|
+
test('renders a date input', () => {
|
|
15
|
+
const { container } = render(<DatePicker />);
|
|
16
|
+
expect(getDateField(container)).toBeInTheDocument();
|
|
12
17
|
});
|
|
13
18
|
|
|
14
19
|
test('renders a calendar toggle button', () => {
|
|
15
20
|
render(<DatePicker />);
|
|
16
|
-
// Button has an icon with screenReaderText="date" (from iconLeftName fallback in Button/Icon)
|
|
17
21
|
expect(screen.getByRole('button', { name: 'Open date picker' })).toBeInTheDocument();
|
|
18
22
|
});
|
|
19
23
|
|
|
@@ -22,6 +26,49 @@ describe('DatePicker', () => {
|
|
|
22
26
|
expect(screen.queryByRole('application')).not.toBeInTheDocument();
|
|
23
27
|
});
|
|
24
28
|
|
|
29
|
+
test('shows empty-state hint copy when empty (native placeholder is unreliable)', () => {
|
|
30
|
+
const { container } = render(<DatePicker />);
|
|
31
|
+
expect(getDateField(container)).toHaveAttribute('placeholder', '');
|
|
32
|
+
expect(screen.getByText('DD/MM/YYYY')).toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('placeholder prop overrides the empty-state hint', () => {
|
|
36
|
+
const { container } = render(<DatePicker placeholder="When?" />);
|
|
37
|
+
expect(getDateField(container)).toHaveAttribute('placeholder', '');
|
|
38
|
+
expect(screen.getByText('When?')).toBeInTheDocument();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('friendly displayFormat changes the empty-state hint', () => {
|
|
42
|
+
const { container } = render(<DatePicker displayFormat="friendly" />);
|
|
43
|
+
expect(getDateField(container)).toHaveAttribute('placeholder', '');
|
|
44
|
+
expect(screen.getByText('Pick a date')).toBeInTheDocument();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('custom placeholder empty state', () => {
|
|
48
|
+
test('uses read-only date input and opens the calendar on pointer down', () => {
|
|
49
|
+
const { container } = render(<DatePicker placeholder="Pick one" />);
|
|
50
|
+
const input = getDateField(container);
|
|
51
|
+
expect(input.readOnly).toBe(true);
|
|
52
|
+
fireEvent.pointerDown(input);
|
|
53
|
+
expect(screen.getByRole('application')).toBeInTheDocument();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('does not open the calendar on keyboard focus alone', () => {
|
|
57
|
+
const { container } = render(<DatePicker placeholder="Pick one" />);
|
|
58
|
+
const input = getDateField(container);
|
|
59
|
+
fireEvent.focus(input);
|
|
60
|
+
expect(screen.queryByRole('application')).not.toBeInTheDocument();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('opens the calendar when the field is activated with a click', async () => {
|
|
64
|
+
const user = userEvent.setup();
|
|
65
|
+
const { container } = render(<DatePicker placeholder="Pick one" />);
|
|
66
|
+
const input = getDateField(container);
|
|
67
|
+
await user.click(input);
|
|
68
|
+
expect(screen.getByRole('application')).toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
25
72
|
describe('calendar toggle', () => {
|
|
26
73
|
test('clicking the button opens the day picker', async () => {
|
|
27
74
|
render(<DatePicker />);
|
|
@@ -37,61 +84,57 @@ describe('DatePicker', () => {
|
|
|
37
84
|
});
|
|
38
85
|
});
|
|
39
86
|
|
|
40
|
-
describe('
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
fireEvent.change(screen.getByRole('textbox'), { target: { value: '15/06/2024' } });
|
|
47
|
-
expect(screen.getByRole('textbox')).toHaveValue('15/06/2024');
|
|
87
|
+
describe('date input', () => {
|
|
88
|
+
test('entering a valid ISO date updates the input value', () => {
|
|
89
|
+
const { container } = render(<DatePicker />);
|
|
90
|
+
const input = getDateField(container);
|
|
91
|
+
fireEvent.change(input, { target: { value: '2024-06-15' } });
|
|
92
|
+
expect(input).toHaveValue('2024-06-15');
|
|
48
93
|
});
|
|
49
94
|
|
|
50
|
-
test('entering a valid date calls onChange with the parsed Date', () => {
|
|
95
|
+
test('entering a valid ISO date calls onChange with the parsed Date', () => {
|
|
51
96
|
const onChange = vi.fn();
|
|
52
|
-
render(<DatePicker onChange={onChange} />);
|
|
53
|
-
|
|
97
|
+
const { container } = render(<DatePicker onChange={onChange} />);
|
|
98
|
+
const input = getDateField(container);
|
|
54
99
|
|
|
55
|
-
fireEvent.change(
|
|
100
|
+
fireEvent.change(input, { target: { value: '2024-06-15' } });
|
|
56
101
|
|
|
102
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
57
103
|
expect(onChange).toHaveBeenLastCalledWith(expect.any(Date));
|
|
58
104
|
const lastDate: Date = onChange.mock.lastCall![0];
|
|
59
105
|
expect(lastDate.getFullYear()).toBe(2024);
|
|
60
|
-
expect(lastDate.getMonth()).toBe(5);
|
|
106
|
+
expect(lastDate.getMonth()).toBe(5);
|
|
61
107
|
expect(lastDate.getDate()).toBe(15);
|
|
108
|
+
expect(lastDate.getHours()).toBe(0);
|
|
109
|
+
expect(lastDate.getMinutes()).toBe(0);
|
|
62
110
|
});
|
|
63
111
|
|
|
64
|
-
test('entering an invalid string
|
|
65
|
-
// onChange is only fired by the useEffect when selectedDate actually changes.
|
|
66
|
-
// So we first need a valid date to put selectedDate into a non-undefined state.
|
|
112
|
+
test('entering an invalid string calls onChange with undefined', () => {
|
|
67
113
|
const onChange = vi.fn();
|
|
68
|
-
render(<DatePicker onChange={onChange} />);
|
|
69
|
-
|
|
70
|
-
onChange.mockClear();
|
|
114
|
+
const { container } = render(<DatePicker onChange={onChange} />);
|
|
115
|
+
const input = getDateField(container);
|
|
71
116
|
|
|
72
|
-
fireEvent.change(
|
|
117
|
+
fireEvent.change(input, { target: { value: '2024-06-15' } });
|
|
118
|
+
onChange.mockClear();
|
|
119
|
+
fireEvent.change(input, { target: { value: 'not-a-date' } });
|
|
73
120
|
|
|
121
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
74
122
|
expect(onChange).toHaveBeenLastCalledWith(undefined);
|
|
75
123
|
});
|
|
76
124
|
});
|
|
77
125
|
|
|
78
126
|
describe('day picker selection', () => {
|
|
79
|
-
// Avoid fake timers (incompatible with userEvent) by navigating the picker to a
|
|
80
|
-
// known month via the text input before opening the calendar.
|
|
81
|
-
|
|
82
127
|
test('selecting a date from the picker updates the input', async () => {
|
|
83
|
-
render(<DatePicker />);
|
|
84
|
-
|
|
85
|
-
fireEvent.change(screen.getByRole('textbox'), { target: { value: '01/06/2024' } });
|
|
128
|
+
const { container } = render(<DatePicker />);
|
|
129
|
+
fireEvent.change(getDateField(container), { target: { value: '2024-06-01' } });
|
|
86
130
|
await userEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
|
87
|
-
// Day aria-labels use date-fns PPPP format e.g. "Thursday, June 20, 2024"
|
|
88
131
|
await userEvent.click(within(screen.getByRole('application')).getByRole('button', { name: /June 20/ }));
|
|
89
|
-
expect(
|
|
132
|
+
expect(getDateField(container)).toHaveValue('2024-06-20');
|
|
90
133
|
});
|
|
91
134
|
|
|
92
135
|
test('selecting a date from the picker closes the picker', async () => {
|
|
93
|
-
render(<DatePicker />);
|
|
94
|
-
fireEvent.change(
|
|
136
|
+
const { container } = render(<DatePicker />);
|
|
137
|
+
fireEvent.change(getDateField(container), { target: { value: '2024-06-01' } });
|
|
95
138
|
await userEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
|
96
139
|
await userEvent.click(within(screen.getByRole('application')).getByRole('button', { name: /June 20/ }));
|
|
97
140
|
expect(screen.queryByRole('application')).not.toBeInTheDocument();
|
|
@@ -99,8 +142,8 @@ describe('DatePicker', () => {
|
|
|
99
142
|
|
|
100
143
|
test('selecting a date from the picker calls onChange with the selected date', async () => {
|
|
101
144
|
const onChange = vi.fn();
|
|
102
|
-
render(<DatePicker onChange={onChange} />);
|
|
103
|
-
fireEvent.change(
|
|
145
|
+
const { container } = render(<DatePicker onChange={onChange} />);
|
|
146
|
+
fireEvent.change(getDateField(container), { target: { value: '2024-06-01' } });
|
|
104
147
|
onChange.mockClear();
|
|
105
148
|
|
|
106
149
|
await userEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
|
@@ -109,35 +152,100 @@ describe('DatePicker', () => {
|
|
|
109
152
|
expect(onChange).toHaveBeenCalledWith(expect.any(Date));
|
|
110
153
|
const lastDate: Date = onChange.mock.lastCall![0];
|
|
111
154
|
expect(lastDate.getFullYear()).toBe(2024);
|
|
112
|
-
expect(lastDate.getMonth()).toBe(5);
|
|
155
|
+
expect(lastDate.getMonth()).toBe(5);
|
|
113
156
|
expect(lastDate.getDate()).toBe(20);
|
|
157
|
+
expect(lastDate.getHours()).toBe(0);
|
|
158
|
+
expect(lastDate.getMinutes()).toBe(0);
|
|
114
159
|
});
|
|
115
160
|
|
|
116
|
-
test('clicking the Today button
|
|
117
|
-
render(<DatePicker />);
|
|
161
|
+
test('clicking the Today button sets today and closes the picker', async () => {
|
|
162
|
+
const { container } = render(<DatePicker />);
|
|
118
163
|
await userEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
|
119
164
|
await userEvent.click(screen.getByRole('button', { name: 'Today' }));
|
|
120
|
-
expect(
|
|
165
|
+
expect(getDateField(container)).toHaveValue(formatNativeDateInputValue(new Date()));
|
|
166
|
+
expect(screen.queryByRole('application')).not.toBeInTheDocument();
|
|
121
167
|
});
|
|
122
168
|
|
|
123
|
-
test('
|
|
124
|
-
render(<DatePicker />);
|
|
169
|
+
test('opening the picker after typing a valid value syncs the month and year controls', async () => {
|
|
170
|
+
const { container } = render(<DatePicker />);
|
|
171
|
+
fireEvent.change(getDateField(container), { target: { value: '2024-06-01' } });
|
|
172
|
+
|
|
125
173
|
await userEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
|
126
|
-
|
|
127
|
-
expect(
|
|
174
|
+
|
|
175
|
+
expect(getDropdownTrigger(/June/i)).toBeInTheDocument();
|
|
176
|
+
expect(getDropdownTrigger(/2024/i)).toBeInTheDocument();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('changing the custom month select updates the displayed calendar month', async () => {
|
|
180
|
+
const user = userEvent.setup();
|
|
181
|
+
const { container } = render(<DatePicker value={new Date(2024, 5, 15)} />);
|
|
182
|
+
|
|
183
|
+
await user.click(screen.getByRole('button', { name: 'Open date picker' }));
|
|
184
|
+
await user.click(getDropdownTrigger(/June/i)!);
|
|
185
|
+
await user.click(screen.getByText('July'));
|
|
186
|
+
|
|
187
|
+
expect(getDropdownTrigger(/July/i)).toBeInTheDocument();
|
|
188
|
+
expect(within(screen.getByRole('application')).getByRole('button', { name: /July 15/ })).toBeInTheDocument();
|
|
189
|
+
expect(getDateField(container)).toHaveValue(formatNativeDateInputValue(new Date(2024, 5, 15)));
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('defaultValue prop', () => {
|
|
194
|
+
test('renders with the defaultValue when no value is provided', () => {
|
|
195
|
+
const { container } = render(<DatePicker defaultValue={new Date(2024, 5, 15)} />);
|
|
196
|
+
expect(getDateField(container)).toHaveValue(formatNativeDateInputValue(new Date(2024, 5, 15)));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('value prop takes precedence over defaultValue', () => {
|
|
200
|
+
const { container } = render(
|
|
201
|
+
<DatePicker value={new Date(2024, 11, 25)} defaultValue={new Date(2024, 5, 15)} />,
|
|
202
|
+
);
|
|
203
|
+
expect(getDateField(container)).toHaveValue(formatNativeDateInputValue(new Date(2024, 11, 25)));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('does not call onChange on mount with defaultValue', () => {
|
|
207
|
+
const onChange = vi.fn();
|
|
208
|
+
render(<DatePicker defaultValue={new Date(2024, 5, 15)} onChange={onChange} />);
|
|
209
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('onChange call-count correctness', () => {
|
|
214
|
+
test('does not call onChange on mount', () => {
|
|
215
|
+
const onChange = vi.fn();
|
|
216
|
+
render(<DatePicker onChange={onChange} />);
|
|
217
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('does not call onChange on mount with an initial value', () => {
|
|
221
|
+
const onChange = vi.fn();
|
|
222
|
+
render(<DatePicker value={new Date(2024, 5, 15)} onChange={onChange} />);
|
|
223
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('calls onChange exactly once per user interaction', async () => {
|
|
227
|
+
const onChange = vi.fn();
|
|
228
|
+
const { container } = render(<DatePicker onChange={onChange} />);
|
|
229
|
+
|
|
230
|
+
fireEvent.change(getDateField(container), { target: { value: '2024-06-15' } });
|
|
231
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
232
|
+
|
|
233
|
+
await userEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
|
234
|
+
await userEvent.click(within(screen.getByRole('application')).getByRole('button', { name: /June 20/ }));
|
|
235
|
+
expect(onChange).toHaveBeenCalledTimes(2);
|
|
128
236
|
});
|
|
129
237
|
});
|
|
130
238
|
|
|
131
239
|
describe('controlled value prop', () => {
|
|
132
240
|
test('renders with the initial value', () => {
|
|
133
|
-
render(<DatePicker value={new Date(2024, 5, 15)} />);
|
|
134
|
-
expect(
|
|
241
|
+
const { container } = render(<DatePicker value={new Date(2024, 5, 15)} />);
|
|
242
|
+
expect(getDateField(container)).toHaveValue(formatNativeDateInputValue(new Date(2024, 5, 15)));
|
|
135
243
|
});
|
|
136
244
|
|
|
137
245
|
test('updates the input when value prop changes', () => {
|
|
138
|
-
const { rerender } = render(<DatePicker value={new Date(2024, 5, 15)} />);
|
|
246
|
+
const { container, rerender } = render(<DatePicker value={new Date(2024, 5, 15)} />);
|
|
139
247
|
rerender(<DatePicker value={new Date(2024, 11, 25)} />);
|
|
140
|
-
expect(
|
|
248
|
+
expect(getDateField(container)).toHaveValue(formatNativeDateInputValue(new Date(2024, 11, 25)));
|
|
141
249
|
});
|
|
142
250
|
|
|
143
251
|
test('does not call onChange when value prop changes externally', () => {
|
|
@@ -155,7 +263,6 @@ describe('DatePicker', () => {
|
|
|
155
263
|
render(<DatePicker value={new Date(2024, 5, 15)} onChange={onChange} />);
|
|
156
264
|
onChange.mockClear();
|
|
157
265
|
|
|
158
|
-
// Navigate to June 2024 (already set via value) and pick a different day
|
|
159
266
|
await userEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
|
160
267
|
await userEvent.click(within(screen.getByRole('application')).getByRole('button', { name: /June 20/ }));
|
|
161
268
|
|
|
@@ -164,26 +271,4 @@ describe('DatePicker', () => {
|
|
|
164
271
|
expect(lastDate.getDate()).toBe(20);
|
|
165
272
|
});
|
|
166
273
|
});
|
|
167
|
-
|
|
168
|
-
describe('custom dateFormat prop', () => {
|
|
169
|
-
test('formats the value according to the custom format', () => {
|
|
170
|
-
render(<DatePicker dateFormat="yyyy-MM-dd" />);
|
|
171
|
-
fireEvent.change(screen.getByRole('textbox'), { target: { value: '2024-06-15' } });
|
|
172
|
-
expect(screen.getByRole('textbox')).toHaveValue('2024-06-15');
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
test('calls onChange with the correctly parsed date for a custom format', () => {
|
|
176
|
-
const onChange = vi.fn();
|
|
177
|
-
render(<DatePicker dateFormat="MM/dd/yyyy" onChange={onChange} />);
|
|
178
|
-
onChange.mockClear();
|
|
179
|
-
|
|
180
|
-
fireEvent.change(screen.getByRole('textbox'), { target: { value: '06/15/2024' } });
|
|
181
|
-
|
|
182
|
-
expect(onChange).toHaveBeenLastCalledWith(expect.any(Date));
|
|
183
|
-
const lastDate: Date = onChange.mock.lastCall![0];
|
|
184
|
-
expect(lastDate.getFullYear()).toBe(2024);
|
|
185
|
-
expect(lastDate.getMonth()).toBe(5); // June
|
|
186
|
-
expect(lastDate.getDate()).toBe(15);
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
274
|
});
|
|
@@ -1,86 +1,155 @@
|
|
|
1
1
|
import classNames from 'classnames';
|
|
2
2
|
import { Button } from 'Components/button/Button';
|
|
3
3
|
import { TextInput } from 'Components/formField/inputs/text/TextInput';
|
|
4
|
-
import { format, isValid, parse } from 'date-fns';
|
|
5
4
|
import { Popover } from 'radix-ui';
|
|
6
|
-
import { useContext, useRef, useState, type ChangeEvent } from 'react';
|
|
5
|
+
import { useContext, useEffect, useRef, useState, type ChangeEvent, type PointerEvent } from 'react';
|
|
7
6
|
import { DayPicker } from 'react-day-picker';
|
|
8
|
-
import { useComponentDidUpdate } from 'Utils/hooks/useComponentDidUpdate';
|
|
9
7
|
import { PopupParentContext } from 'Utils/PopupParentContext';
|
|
8
|
+
import {
|
|
9
|
+
formatNativeDateInputValue,
|
|
10
|
+
formatTimeInputValue,
|
|
11
|
+
getDatePlaceholder,
|
|
12
|
+
mergeDateAndTime,
|
|
13
|
+
parseNativeDateInputValue,
|
|
14
|
+
type DateDisplayFormat,
|
|
15
|
+
} from './dateInputUtils';
|
|
16
|
+
import { DatePickerCalendarHeader } from './DatePickerCalendarHeader';
|
|
17
|
+
|
|
18
|
+
const TIME_PRESERVATION_GRANULARITY = 'minute' as const;
|
|
10
19
|
|
|
11
20
|
export type DatePickerProps = {
|
|
12
21
|
'className'?: string;
|
|
13
|
-
'
|
|
22
|
+
'displayFormat'?: DateDisplayFormat;
|
|
23
|
+
'placeholder'?: string;
|
|
14
24
|
'onChange'?: (newDate?: Date) => void;
|
|
15
25
|
'id'?: string;
|
|
16
26
|
'hasError'?: boolean;
|
|
17
27
|
'aria-describedby'?: string;
|
|
18
28
|
'aria-invalid'?: boolean;
|
|
19
29
|
'value'?: Date;
|
|
30
|
+
'defaultValue'?: Date;
|
|
20
31
|
};
|
|
21
32
|
|
|
22
33
|
export const DatePicker = (props: DatePickerProps) => {
|
|
23
34
|
const {
|
|
24
35
|
className,
|
|
25
|
-
|
|
36
|
+
displayFormat = 'default',
|
|
26
37
|
onChange,
|
|
27
38
|
id,
|
|
28
39
|
hasError,
|
|
29
40
|
'aria-describedby': ariaDescribedBy,
|
|
30
41
|
'aria-invalid': ariaInvalid,
|
|
31
42
|
value,
|
|
43
|
+
defaultValue,
|
|
44
|
+
placeholder,
|
|
32
45
|
} = props;
|
|
33
46
|
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
const [
|
|
47
|
+
const initialValue = value ?? defaultValue;
|
|
48
|
+
const isControlled = value !== undefined;
|
|
49
|
+
const [month, setMonth] = useState(initialValue ?? new Date());
|
|
50
|
+
const [selectedDate, setSelectedDate] = useState<Date | undefined>(initialValue);
|
|
51
|
+
const [inputValue, setInputValue] = useState(
|
|
52
|
+
initialValue ? formatNativeDateInputValue(initialValue) : '',
|
|
53
|
+
);
|
|
54
|
+
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
|
37
55
|
|
|
38
|
-
const
|
|
56
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
57
|
+
const selectedDateRef = useRef(selectedDate);
|
|
58
|
+
selectedDateRef.current = selectedDate;
|
|
39
59
|
|
|
40
|
-
const
|
|
60
|
+
const resolvedPlaceholder = placeholder ?? getDatePlaceholder(displayFormat);
|
|
61
|
+
const showFieldHint = selectedDate === undefined && inputValue === '';
|
|
62
|
+
const overlayHint = resolvedPlaceholder;
|
|
63
|
+
const hasCustomEmptyHint = showFieldHint && placeholder !== undefined;
|
|
41
64
|
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!isControlled) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const valueTime = value?.getTime();
|
|
70
|
+
const selectedTime = selectedDateRef.current?.getTime();
|
|
71
|
+
if (valueTime !== selectedTime) {
|
|
45
72
|
setSelectedDate(value);
|
|
46
|
-
setMonth(value);
|
|
73
|
+
setMonth(value ?? new Date());
|
|
74
|
+
setInputValue(value ? formatNativeDateInputValue(value) : '');
|
|
47
75
|
}
|
|
48
|
-
}, [value]);
|
|
49
|
-
|
|
50
|
-
const
|
|
76
|
+
}, [value, isControlled]);
|
|
77
|
+
|
|
78
|
+
const mergeParsedDateWithExistingTime = (parsedDay: Date) => (
|
|
79
|
+
selectedDate
|
|
80
|
+
? mergeDateAndTime(
|
|
81
|
+
parsedDay,
|
|
82
|
+
formatTimeInputValue(selectedDate, TIME_PRESERVATION_GRANULARITY),
|
|
83
|
+
TIME_PRESERVATION_GRANULARITY,
|
|
84
|
+
)
|
|
85
|
+
: parsedDay
|
|
86
|
+
);
|
|
51
87
|
|
|
52
88
|
const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
53
|
-
|
|
89
|
+
const nextInputValue = e.target.value;
|
|
90
|
+
setInputValue(nextInputValue);
|
|
54
91
|
|
|
55
|
-
const
|
|
92
|
+
const parsedDay = parseNativeDateInputValue(nextInputValue);
|
|
56
93
|
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
|
|
94
|
+
if (parsedDay) {
|
|
95
|
+
const next = mergeParsedDateWithExistingTime(parsedDay);
|
|
96
|
+
setSelectedDate(next);
|
|
97
|
+
setMonth(next);
|
|
98
|
+
onChange?.(next);
|
|
60
99
|
}
|
|
61
100
|
else {
|
|
62
101
|
setSelectedDate(undefined);
|
|
102
|
+
onChange?.(undefined);
|
|
63
103
|
}
|
|
64
104
|
};
|
|
65
105
|
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
106
|
+
const mergePreservingTime = (calendarDay: Date) => {
|
|
107
|
+
const timeSource = selectedDate ?? calendarDay;
|
|
108
|
+
return mergeDateAndTime(
|
|
109
|
+
calendarDay,
|
|
110
|
+
formatTimeInputValue(timeSource, TIME_PRESERVATION_GRANULARITY),
|
|
111
|
+
TIME_PRESERVATION_GRANULARITY,
|
|
112
|
+
);
|
|
69
113
|
};
|
|
70
114
|
|
|
71
|
-
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
else {
|
|
115
|
+
const onDayPickerSelect = (date?: Date) => {
|
|
116
|
+
if (!date) {
|
|
117
|
+
setSelectedDate(undefined);
|
|
76
118
|
setInputValue('');
|
|
119
|
+
setIsPickerOpen(false);
|
|
120
|
+
onChange?.(undefined);
|
|
121
|
+
return;
|
|
77
122
|
}
|
|
78
123
|
|
|
79
|
-
|
|
80
|
-
|
|
124
|
+
const next = mergePreservingTime(date);
|
|
125
|
+
setSelectedDate(next);
|
|
126
|
+
setMonth(next);
|
|
127
|
+
setInputValue(formatNativeDateInputValue(next));
|
|
128
|
+
setIsPickerOpen(false);
|
|
129
|
+
onChange?.(next);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const handleTodayClick = () => {
|
|
133
|
+
const day = new Date();
|
|
134
|
+
const next = mergeDateAndTime(
|
|
135
|
+
day,
|
|
136
|
+
formatTimeInputValue(selectedDate ?? day, TIME_PRESERVATION_GRANULARITY),
|
|
137
|
+
TIME_PRESERVATION_GRANULARITY,
|
|
138
|
+
);
|
|
139
|
+
setSelectedDate(next);
|
|
140
|
+
setMonth(next);
|
|
141
|
+
setInputValue(formatNativeDateInputValue(next));
|
|
142
|
+
setIsPickerOpen(false);
|
|
143
|
+
onChange?.(next);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** Custom empty hint: open only on pointer (not Tab focus) so keyboard users are not surprised by the popover. */
|
|
147
|
+
const onFieldHintInputPointerDown = (e: PointerEvent<HTMLInputElement>) => {
|
|
148
|
+
if (hasCustomEmptyHint) {
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
setIsPickerOpen(true);
|
|
81
151
|
}
|
|
82
|
-
|
|
83
|
-
}, [selectedDate, dateFormat, onChange]);
|
|
152
|
+
};
|
|
84
153
|
|
|
85
154
|
const popupParentRef = useContext(PopupParentContext);
|
|
86
155
|
|
|
@@ -91,20 +160,37 @@ export const DatePicker = (props: DatePickerProps) => {
|
|
|
91
160
|
className,
|
|
92
161
|
)}
|
|
93
162
|
>
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
163
|
+
<div
|
|
164
|
+
className={classNames(
|
|
165
|
+
'ds-date-picker__field',
|
|
166
|
+
{ 'ds-date-picker__field--show-hint': showFieldHint },
|
|
167
|
+
)}
|
|
168
|
+
>
|
|
169
|
+
<TextInput
|
|
170
|
+
ref={inputRef}
|
|
171
|
+
type="date"
|
|
172
|
+
value={inputValue}
|
|
173
|
+
onChange={onInputChange}
|
|
174
|
+
onPointerDown={onFieldHintInputPointerDown}
|
|
175
|
+
readOnly={hasCustomEmptyHint}
|
|
176
|
+
id={id}
|
|
177
|
+
placeholder={showFieldHint ? '' : resolvedPlaceholder}
|
|
178
|
+
hasError={hasError}
|
|
179
|
+
aria-describedby={ariaDescribedBy}
|
|
180
|
+
aria-invalid={ariaInvalid}
|
|
181
|
+
className="ds-date-picker__input"
|
|
182
|
+
/>
|
|
183
|
+
{showFieldHint
|
|
184
|
+
? (
|
|
185
|
+
<span className="ds-date-picker__field-hint" aria-hidden="true">
|
|
186
|
+
{overlayHint}
|
|
187
|
+
</span>
|
|
188
|
+
)
|
|
189
|
+
: null}
|
|
190
|
+
</div>
|
|
102
191
|
<Popover.Root open={isPickerOpen} onOpenChange={open => setIsPickerOpen(open)}>
|
|
103
192
|
<Popover.Trigger asChild>
|
|
104
193
|
<Button
|
|
105
|
-
onClick={() => {
|
|
106
|
-
setIsPickerOpen(!isPickerOpen);
|
|
107
|
-
}}
|
|
108
194
|
className="ds-date-picker__button"
|
|
109
195
|
variant="text-link"
|
|
110
196
|
iconLeftName="date"
|
|
@@ -112,30 +198,38 @@ export const DatePicker = (props: DatePickerProps) => {
|
|
|
112
198
|
/>
|
|
113
199
|
</Popover.Trigger>
|
|
114
200
|
<Popover.Portal container={popupParentRef.current}>
|
|
115
|
-
<Popover.Content
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
201
|
+
<Popover.Content
|
|
202
|
+
align="end"
|
|
203
|
+
sideOffset={5}
|
|
204
|
+
onOpenAutoFocus={e => e.preventDefault()}
|
|
205
|
+
onCloseAutoFocus={(e) => {
|
|
206
|
+
e.preventDefault();
|
|
207
|
+
inputRef.current?.focus();
|
|
208
|
+
}}
|
|
209
|
+
>
|
|
210
|
+
<div className="ds-date-picker__popup">
|
|
211
|
+
<DatePickerCalendarHeader month={month} onMonthChange={setMonth} />
|
|
212
|
+
<DayPicker
|
|
213
|
+
className="ds-date-picker__calendar"
|
|
214
|
+
month={month}
|
|
215
|
+
onMonthChange={setMonth}
|
|
216
|
+
autoFocus
|
|
217
|
+
role="application"
|
|
218
|
+
mode="single"
|
|
219
|
+
selected={selectedDate}
|
|
220
|
+
onSelect={onDayPickerSelect}
|
|
221
|
+
hideNavigation
|
|
222
|
+
footer={(
|
|
223
|
+
<Button
|
|
224
|
+
variant="text-link"
|
|
225
|
+
onClick={handleTodayClick}
|
|
226
|
+
className="ds-date-picker__today-button"
|
|
227
|
+
>
|
|
228
|
+
Today
|
|
229
|
+
</Button>
|
|
230
|
+
)}
|
|
231
|
+
/>
|
|
232
|
+
</div>
|
|
139
233
|
</Popover.Content>
|
|
140
234
|
</Popover.Portal>
|
|
141
235
|
</Popover.Root>
|