@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
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest';
|
|
2
|
+
import { fireEvent, render, screen, within } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
|
5
|
+
import { DateTimePicker } from './DateTimePicker';
|
|
6
|
+
import { getDropdownTrigger } from 'Components/datePicker/datePickerTestUtils.test-helpers';
|
|
7
|
+
|
|
8
|
+
describe('DateTimePicker', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
globalThis.ResizeObserver = class {
|
|
11
|
+
observe() {}
|
|
12
|
+
unobserve() {}
|
|
13
|
+
disconnect() {}
|
|
14
|
+
} as unknown as typeof ResizeObserver;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.restoreAllMocks();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('renders a date input and a picker toggle button', () => {
|
|
22
|
+
const { container } = render(<DateTimePicker />);
|
|
23
|
+
|
|
24
|
+
expect(container.querySelector('input[type="datetime-local"]')).toBeInTheDocument();
|
|
25
|
+
expect(screen.getByRole('button', { name: 'Open date and time picker' })).toBeInTheDocument();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('uses a native datetime-local input with minute stepping by default', () => {
|
|
29
|
+
const { container } = render(<DateTimePicker />);
|
|
30
|
+
const input = container.querySelector('input[type="datetime-local"]');
|
|
31
|
+
|
|
32
|
+
expect(input).toHaveAttribute('step', '60');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('shows default native empty-state hint when empty', () => {
|
|
36
|
+
const { container } = render(<DateTimePicker />);
|
|
37
|
+
const input = container.querySelector('input[type="datetime-local"]');
|
|
38
|
+
expect(input).toHaveAttribute('placeholder', '');
|
|
39
|
+
expect(screen.getByText('YYYY-MM-DDTHH:mm')).toBeInTheDocument();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('shows native second-granularity empty-state hint', () => {
|
|
43
|
+
const { container } = render(<DateTimePicker granularity="second" />);
|
|
44
|
+
const input = container.querySelector('input[type="datetime-local"]');
|
|
45
|
+
expect(input).toHaveAttribute('placeholder', '');
|
|
46
|
+
expect(screen.getByText('YYYY-MM-DDTHH:mm:ss')).toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('allows empty-state hint to be overridden', () => {
|
|
50
|
+
const { container } = render(<DateTimePicker placeholder="Choose..." />);
|
|
51
|
+
const input = container.querySelector('input[type="datetime-local"]');
|
|
52
|
+
expect(input).toHaveAttribute('placeholder', '');
|
|
53
|
+
expect(screen.getByText('Choose...')).toBeInTheDocument();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('native mode with custom placeholder uses read-only date field and opens calendar on pointer down', () => {
|
|
57
|
+
const { container } = render(<DateTimePicker placeholder="Choose..." />);
|
|
58
|
+
const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
|
|
59
|
+
expect(input.readOnly).toBe(true);
|
|
60
|
+
fireEvent.pointerDown(input);
|
|
61
|
+
expect(screen.getByRole('application')).toBeInTheDocument();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('native mode with custom placeholder does not open calendar on keyboard focus alone', () => {
|
|
65
|
+
const { container } = render(<DateTimePicker placeholder="Choose..." />);
|
|
66
|
+
const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
|
|
67
|
+
fireEvent.focus(input);
|
|
68
|
+
expect(screen.queryByRole('application')).not.toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('displayFormat branching', () => {
|
|
72
|
+
test('displayFormat="native" renders datetime-local', () => {
|
|
73
|
+
const { container } = render(<DateTimePicker displayFormat="native" />);
|
|
74
|
+
expect(container.querySelector('input[type="datetime-local"]')).toBeInTheDocument();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('displayFormat="default" renders a text input with formatted placeholder', () => {
|
|
78
|
+
render(<DateTimePicker displayFormat="default" />);
|
|
79
|
+
const input = screen.getByRole('textbox');
|
|
80
|
+
expect(input).toHaveAttribute('placeholder', 'DD/MM/YYYY HH:mm');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('displayFormat="friendly" renders a text input with friendly placeholder', () => {
|
|
84
|
+
render(<DateTimePicker displayFormat="friendly" />);
|
|
85
|
+
const input = screen.getByRole('textbox');
|
|
86
|
+
expect(input).toHaveAttribute('placeholder', 'Pick a date, HH:mm');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('typing formatted text in default mode parses correctly', () => {
|
|
90
|
+
const onChange = vi.fn();
|
|
91
|
+
render(<DateTimePicker displayFormat="default" onChange={onChange} />);
|
|
92
|
+
const input = screen.getByRole('textbox');
|
|
93
|
+
fireEvent.change(input, { target: { value: '15/06/2024 14:30' } });
|
|
94
|
+
|
|
95
|
+
expect(onChange).toHaveBeenLastCalledWith(expect.any(Date));
|
|
96
|
+
const lastDate: Date = onChange.mock.lastCall![0];
|
|
97
|
+
expect(lastDate.getFullYear()).toBe(2024);
|
|
98
|
+
expect(lastDate.getMonth()).toBe(5);
|
|
99
|
+
expect(lastDate.getDate()).toBe(15);
|
|
100
|
+
expect(lastDate.getHours()).toBe(14);
|
|
101
|
+
expect(lastDate.getMinutes()).toBe(30);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('typing formatted text in friendly mode parses correctly', () => {
|
|
105
|
+
const onChange = vi.fn();
|
|
106
|
+
render(<DateTimePicker displayFormat="friendly" onChange={onChange} />);
|
|
107
|
+
const input = screen.getByRole('textbox');
|
|
108
|
+
fireEvent.change(input, { target: { value: 'June 15th, 2024 14:30' } });
|
|
109
|
+
|
|
110
|
+
expect(onChange).toHaveBeenLastCalledWith(expect.any(Date));
|
|
111
|
+
const lastDate: Date = onChange.mock.lastCall![0];
|
|
112
|
+
expect(lastDate.getFullYear()).toBe(2024);
|
|
113
|
+
expect(lastDate.getMonth()).toBe(5);
|
|
114
|
+
expect(lastDate.getDate()).toBe(15);
|
|
115
|
+
expect(lastDate.getHours()).toBe(14);
|
|
116
|
+
expect(lastDate.getMinutes()).toBe(30);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('controlled value displays in default format', () => {
|
|
120
|
+
render(<DateTimePicker displayFormat="default" value={new Date(2024, 5, 15, 14, 30)} />);
|
|
121
|
+
const input = screen.getByRole('textbox');
|
|
122
|
+
expect(input).toHaveValue('15/06/2024 14:30');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('controlled value displays in friendly format', () => {
|
|
126
|
+
render(<DateTimePicker displayFormat="friendly" value={new Date(2024, 5, 15, 14, 30)} />);
|
|
127
|
+
const input = screen.getByRole('textbox');
|
|
128
|
+
expect(input).toHaveValue('June 15th, 2024 14:30');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('typing a valid combined value emits a combined date and time value', () => {
|
|
133
|
+
const onChange = vi.fn();
|
|
134
|
+
const { container } = render(<DateTimePicker onChange={onChange} />);
|
|
135
|
+
const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
|
|
136
|
+
|
|
137
|
+
fireEvent.change(input, { target: { value: '2024-06-15T14:30' } });
|
|
138
|
+
|
|
139
|
+
expect(onChange).toHaveBeenLastCalledWith(expect.any(Date));
|
|
140
|
+
const lastDate: Date = onChange.mock.lastCall![0];
|
|
141
|
+
expect(lastDate.getFullYear()).toBe(2024);
|
|
142
|
+
expect(lastDate.getMonth()).toBe(5);
|
|
143
|
+
expect(lastDate.getDate()).toBe(15);
|
|
144
|
+
expect(lastDate.getHours()).toBe(14);
|
|
145
|
+
expect(lastDate.getMinutes()).toBe(30);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('uses one-second stepping when granularity is second', () => {
|
|
149
|
+
const { container } = render(<DateTimePicker granularity="second" />);
|
|
150
|
+
const input = container.querySelector('input[type="datetime-local"]');
|
|
151
|
+
|
|
152
|
+
expect(input).toHaveAttribute('step', '1');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('selecting a day from the calendar preserves the existing time', async () => {
|
|
156
|
+
const user = userEvent.setup();
|
|
157
|
+
const onChange = vi.fn();
|
|
158
|
+
|
|
159
|
+
render(<DateTimePicker value={new Date(2024, 5, 1, 14, 30)} onChange={onChange} />);
|
|
160
|
+
onChange.mockClear();
|
|
161
|
+
|
|
162
|
+
await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
|
|
163
|
+
await user.click(within(screen.getByRole('application')).getByRole('button', { name: /June 20/ }));
|
|
164
|
+
|
|
165
|
+
expect(onChange).toHaveBeenLastCalledWith(expect.any(Date));
|
|
166
|
+
const lastDate: Date = onChange.mock.lastCall![0];
|
|
167
|
+
expect(lastDate.getFullYear()).toBe(2024);
|
|
168
|
+
expect(lastDate.getMonth()).toBe(5);
|
|
169
|
+
expect(lastDate.getDate()).toBe(20);
|
|
170
|
+
expect(lastDate.getHours()).toBe(14);
|
|
171
|
+
expect(lastDate.getMinutes()).toBe(30);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('opening the picker after typing a valid combined value syncs month, year, and time controls', async () => {
|
|
175
|
+
const user = userEvent.setup();
|
|
176
|
+
const { container } = render(<DateTimePicker />);
|
|
177
|
+
const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
|
|
178
|
+
|
|
179
|
+
fireEvent.change(input, { target: { value: '2026-04-14T14:50' } });
|
|
180
|
+
await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
|
|
181
|
+
|
|
182
|
+
expect(getDropdownTrigger(/April/i)).toBeInTheDocument();
|
|
183
|
+
expect(getDropdownTrigger(/2026/i)).toBeInTheDocument();
|
|
184
|
+
expect(screen.getByLabelText('Select time', { selector: 'input' })).toHaveValue('14:50');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('time-list mode emits a combined date when an allowed time is selected', async () => {
|
|
188
|
+
const user = userEvent.setup();
|
|
189
|
+
const onChange = vi.fn();
|
|
190
|
+
|
|
191
|
+
render(
|
|
192
|
+
<DateTimePicker
|
|
193
|
+
value={new Date(2024, 5, 15, 13, 0)}
|
|
194
|
+
onChange={onChange}
|
|
195
|
+
timeOptions={['13:00', '13:30', '14:00']}
|
|
196
|
+
/>,
|
|
197
|
+
);
|
|
198
|
+
onChange.mockClear();
|
|
199
|
+
|
|
200
|
+
await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
|
|
201
|
+
await user.click(screen.getByText('13:00'));
|
|
202
|
+
await user.click(screen.getByText('13:30'));
|
|
203
|
+
|
|
204
|
+
expect(onChange).toHaveBeenLastCalledWith(expect.any(Date));
|
|
205
|
+
const lastDate: Date = onChange.mock.lastCall![0];
|
|
206
|
+
expect(lastDate.getFullYear()).toBe(2024);
|
|
207
|
+
expect(lastDate.getMonth()).toBe(5);
|
|
208
|
+
expect(lastDate.getDate()).toBe(15);
|
|
209
|
+
expect(lastDate.getHours()).toBe(13);
|
|
210
|
+
expect(lastDate.getMinutes()).toBe(30);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('defaultValue prop', () => {
|
|
214
|
+
test('renders with the defaultValue when no value is provided', () => {
|
|
215
|
+
const { container } = render(<DateTimePicker defaultValue={new Date(2024, 5, 15, 14, 30)} />);
|
|
216
|
+
expect(container.querySelector('input[type="datetime-local"]')).toHaveValue('2024-06-15T14:30');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('value prop takes precedence over defaultValue', () => {
|
|
220
|
+
const { container } = render(
|
|
221
|
+
<DateTimePicker value={new Date(2024, 11, 25, 9, 0)} defaultValue={new Date(2024, 5, 15, 14, 30)} />,
|
|
222
|
+
);
|
|
223
|
+
expect(container.querySelector('input[type="datetime-local"]')).toHaveValue('2024-12-25T09:00');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('does not call onChange on mount with defaultValue', () => {
|
|
227
|
+
const onChange = vi.fn();
|
|
228
|
+
render(<DateTimePicker defaultValue={new Date(2024, 5, 15, 14, 30)} onChange={onChange} />);
|
|
229
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('onChange call-count correctness', () => {
|
|
234
|
+
test('does not call onChange on mount', () => {
|
|
235
|
+
const onChange = vi.fn();
|
|
236
|
+
render(<DateTimePicker onChange={onChange} />);
|
|
237
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('does not call onChange on mount with an initial value', () => {
|
|
241
|
+
const onChange = vi.fn();
|
|
242
|
+
render(<DateTimePicker value={new Date(2024, 5, 15, 14, 30)} onChange={onChange} />);
|
|
243
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('calls onChange exactly once per user input change', () => {
|
|
247
|
+
const onChange = vi.fn();
|
|
248
|
+
const { container } = render(<DateTimePicker onChange={onChange} />);
|
|
249
|
+
const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
|
|
250
|
+
|
|
251
|
+
fireEvent.change(input, { target: { value: '2024-06-15T14:30' } });
|
|
252
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('controlled value updates sync the date and time fields without firing onChange', () => {
|
|
257
|
+
const onChange = vi.fn();
|
|
258
|
+
const { rerender, container } = render(
|
|
259
|
+
<DateTimePicker value={new Date(2024, 5, 15, 14, 30)} onChange={onChange} />,
|
|
260
|
+
);
|
|
261
|
+
fireEvent.click(screen.getByRole('button', { name: 'Open date and time picker' }));
|
|
262
|
+
|
|
263
|
+
expect(container.querySelector('input[type="datetime-local"]')).toHaveValue('2024-06-15T14:30');
|
|
264
|
+
expect(screen.getByLabelText('Select time', { selector: 'input' })).toHaveValue('14:30');
|
|
265
|
+
|
|
266
|
+
onChange.mockClear();
|
|
267
|
+
|
|
268
|
+
rerender(<DateTimePicker value={new Date(2024, 11, 25, 9, 45)} onChange={onChange} />);
|
|
269
|
+
|
|
270
|
+
expect(container.querySelector('input[type="datetime-local"]')).toHaveValue('2024-12-25T09:45');
|
|
271
|
+
expect(screen.getByLabelText('Select time', { selector: 'input' })).toHaveValue('09:45');
|
|
272
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('Today preserves the current time and closes the picker', async () => {
|
|
276
|
+
const user = userEvent.setup();
|
|
277
|
+
const onChange = vi.fn();
|
|
278
|
+
|
|
279
|
+
render(<DateTimePicker value={new Date(2024, 5, 15, 14, 30)} onChange={onChange} />);
|
|
280
|
+
onChange.mockClear();
|
|
281
|
+
|
|
282
|
+
await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
|
|
283
|
+
await user.click(screen.getByRole('button', { name: 'Today' }));
|
|
284
|
+
|
|
285
|
+
expect(screen.queryByRole('application')).not.toBeInTheDocument();
|
|
286
|
+
expect(onChange).toHaveBeenLastCalledWith(expect.any(Date));
|
|
287
|
+
const lastDate: Date = onChange.mock.lastCall![0];
|
|
288
|
+
const now = new Date();
|
|
289
|
+
expect(lastDate.getFullYear()).toBe(now.getFullYear());
|
|
290
|
+
expect(lastDate.getMonth()).toBe(now.getMonth());
|
|
291
|
+
expect(lastDate.getDate()).toBe(now.getDate());
|
|
292
|
+
expect(lastDate.getHours()).toBe(14);
|
|
293
|
+
expect(lastDate.getMinutes()).toBe(30);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import classNames from 'classnames';
|
|
2
|
+
import { Button } from 'Components/button/Button';
|
|
3
|
+
import type { ComboboxSearchType } from 'Components/combobox/types';
|
|
4
|
+
import {
|
|
5
|
+
formatDateTimeInputValue,
|
|
6
|
+
formatNativeDateTimeInputValue,
|
|
7
|
+
formatTimeInputValue,
|
|
8
|
+
getDateTimePlaceholder,
|
|
9
|
+
getNativeDateTimePlaceholder,
|
|
10
|
+
mergeDateAndTime,
|
|
11
|
+
parseDateTimeInputValue,
|
|
12
|
+
parseNativeDateTimeInputValue,
|
|
13
|
+
type DateDisplayFormat,
|
|
14
|
+
type DateTimePickerDisplayFormat,
|
|
15
|
+
} from 'Components/datePicker/dateInputUtils';
|
|
16
|
+
import { DatePickerCalendarHeader } from 'Components/datePicker/DatePickerCalendarHeader';
|
|
17
|
+
import { TextInput } from 'Components/formField/inputs/text/TextInput';
|
|
18
|
+
import { TimeInput, type TimeGranularity, type TimeValue } from 'Components/formField/inputs/time/TimeInput';
|
|
19
|
+
import { Popover } from 'radix-ui';
|
|
20
|
+
import { useCallback, useContext, useEffect, useRef, useState, type ChangeEvent, type PointerEvent } from 'react';
|
|
21
|
+
import { DayPicker } from 'react-day-picker';
|
|
22
|
+
import { PopupParentContext } from 'Utils/PopupParentContext';
|
|
23
|
+
|
|
24
|
+
export type DateTimePickerProps = {
|
|
25
|
+
'className'?: string;
|
|
26
|
+
'onChange'?: (newDate?: Date) => void;
|
|
27
|
+
'id'?: string;
|
|
28
|
+
'hasError'?: boolean;
|
|
29
|
+
'aria-describedby'?: string;
|
|
30
|
+
'aria-invalid'?: boolean;
|
|
31
|
+
'value'?: Date;
|
|
32
|
+
'defaultValue'?: Date;
|
|
33
|
+
'granularity'?: TimeGranularity;
|
|
34
|
+
'displayFormat'?: DateTimePickerDisplayFormat;
|
|
35
|
+
'timeOptions'?: TimeValue[];
|
|
36
|
+
'searchType'?: ComboboxSearchType;
|
|
37
|
+
'highlightStringMatches'?: boolean;
|
|
38
|
+
'placeholder'?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const DateTimePicker = (props: DateTimePickerProps) => {
|
|
42
|
+
const {
|
|
43
|
+
className,
|
|
44
|
+
onChange,
|
|
45
|
+
id,
|
|
46
|
+
hasError,
|
|
47
|
+
'aria-describedby': ariaDescribedBy,
|
|
48
|
+
'aria-invalid': ariaInvalid,
|
|
49
|
+
value,
|
|
50
|
+
defaultValue,
|
|
51
|
+
displayFormat = 'native',
|
|
52
|
+
granularity = 'minute',
|
|
53
|
+
timeOptions,
|
|
54
|
+
searchType = 'prefix',
|
|
55
|
+
highlightStringMatches = false,
|
|
56
|
+
placeholder,
|
|
57
|
+
} = props;
|
|
58
|
+
|
|
59
|
+
const isNative = displayFormat === 'native';
|
|
60
|
+
|
|
61
|
+
const formatInputValue = useCallback(
|
|
62
|
+
(date: Date) =>
|
|
63
|
+
displayFormat === 'native'
|
|
64
|
+
? formatNativeDateTimeInputValue(date, granularity)
|
|
65
|
+
: formatDateTimeInputValue(date, displayFormat as DateDisplayFormat, granularity),
|
|
66
|
+
[displayFormat, granularity],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const parseInputValue = useCallback(
|
|
70
|
+
(raw: string) =>
|
|
71
|
+
displayFormat === 'native'
|
|
72
|
+
? parseNativeDateTimeInputValue(raw, granularity)
|
|
73
|
+
: parseDateTimeInputValue(raw, displayFormat as DateDisplayFormat, granularity),
|
|
74
|
+
[displayFormat, granularity],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const resolvedPlaceholder = placeholder ?? (
|
|
78
|
+
isNative
|
|
79
|
+
? getNativeDateTimePlaceholder(granularity)
|
|
80
|
+
: getDateTimePlaceholder(displayFormat as DateDisplayFormat, granularity)
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const initialValue = value ?? defaultValue;
|
|
84
|
+
const isControlled = value !== undefined;
|
|
85
|
+
const [month, setMonth] = useState(initialValue ?? new Date());
|
|
86
|
+
const [selectedDateTime, setSelectedDateTime] = useState<Date | undefined>(initialValue);
|
|
87
|
+
const [inputValue, setInputValue] = useState(
|
|
88
|
+
initialValue ? formatInputValue(initialValue) : '',
|
|
89
|
+
);
|
|
90
|
+
const [timeInputValue, setTimeInputValue] = useState(initialValue ? formatTimeInputValue(initialValue, granularity) : '');
|
|
91
|
+
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
|
92
|
+
|
|
93
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
94
|
+
const selectedDateTimeRef = useRef(selectedDateTime);
|
|
95
|
+
selectedDateTimeRef.current = selectedDateTime;
|
|
96
|
+
|
|
97
|
+
const showFieldHint = isNative && selectedDateTime === undefined && inputValue === '';
|
|
98
|
+
const hasCustomEmptyHint = showFieldHint && placeholder !== undefined;
|
|
99
|
+
|
|
100
|
+
/** Custom empty hint in native mode: open only on pointer (not Tab focus). */
|
|
101
|
+
const onNativeFieldHintPointerDown = (e: PointerEvent<HTMLInputElement>) => {
|
|
102
|
+
if (hasCustomEmptyHint) {
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
setIsPickerOpen(true);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!isControlled) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const valueTime = value?.getTime();
|
|
113
|
+
const selectedTime = selectedDateTimeRef.current?.getTime();
|
|
114
|
+
if (valueTime !== selectedTime) {
|
|
115
|
+
setSelectedDateTime(value);
|
|
116
|
+
setMonth(value ?? new Date());
|
|
117
|
+
setInputValue(value ? formatInputValue(value) : '');
|
|
118
|
+
setTimeInputValue(value ? formatTimeInputValue(value, granularity) : '');
|
|
119
|
+
}
|
|
120
|
+
}, [value, isControlled, granularity, formatInputValue]);
|
|
121
|
+
|
|
122
|
+
const mountedRef = useRef(false);
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
// Skip reformat on initial mount (`useState` already seeded input/time strings). Only re-run when
|
|
125
|
+
// `granularity` or `displayFormat` changes after mount.
|
|
126
|
+
if (!mountedRef.current) {
|
|
127
|
+
mountedRef.current = true;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const current = selectedDateTimeRef.current;
|
|
131
|
+
if (current) {
|
|
132
|
+
setInputValue(formatInputValue(current));
|
|
133
|
+
setTimeInputValue(formatTimeInputValue(current, granularity));
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
setInputValue('');
|
|
137
|
+
setTimeInputValue('');
|
|
138
|
+
}
|
|
139
|
+
}, [granularity, displayFormat, formatInputValue]);
|
|
140
|
+
|
|
141
|
+
const onInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
142
|
+
const nextInputValue = event.target.value;
|
|
143
|
+
setInputValue(nextInputValue);
|
|
144
|
+
|
|
145
|
+
const parsedDate = parseInputValue(nextInputValue);
|
|
146
|
+
|
|
147
|
+
if (parsedDate) {
|
|
148
|
+
setSelectedDateTime(parsedDate);
|
|
149
|
+
setMonth(parsedDate);
|
|
150
|
+
setTimeInputValue(formatTimeInputValue(parsedDate, granularity));
|
|
151
|
+
onChange?.(parsedDate);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
setSelectedDateTime(undefined);
|
|
155
|
+
onChange?.(undefined);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const handleTimeChange = (nextTimeValue: string) => {
|
|
160
|
+
setTimeInputValue(nextTimeValue);
|
|
161
|
+
|
|
162
|
+
if (!selectedDateTime) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const nextDateTime = mergeDateAndTime(selectedDateTime, nextTimeValue, granularity);
|
|
167
|
+
setSelectedDateTime(nextDateTime);
|
|
168
|
+
setInputValue(formatInputValue(nextDateTime));
|
|
169
|
+
onChange?.(nextDateTime);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const handleDayPickerSelect = (nextDate?: Date) => {
|
|
173
|
+
if (!nextDate) {
|
|
174
|
+
setSelectedDateTime(undefined);
|
|
175
|
+
setInputValue('');
|
|
176
|
+
setIsPickerOpen(false);
|
|
177
|
+
onChange?.(undefined);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const nextDateTime = mergeDateAndTime(nextDate, timeInputValue, granularity);
|
|
182
|
+
setSelectedDateTime(nextDateTime);
|
|
183
|
+
setMonth(nextDateTime);
|
|
184
|
+
setInputValue(formatInputValue(nextDateTime));
|
|
185
|
+
// UX trade-off: closing here means the user must reopen the popover to set time if they picked the day first.
|
|
186
|
+
// Alternatives (keep open until time is chosen) are a deliberate product change.
|
|
187
|
+
setIsPickerOpen(false);
|
|
188
|
+
onChange?.(nextDateTime);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const handleTodayClick = () => {
|
|
192
|
+
const nextDate = mergeDateAndTime(new Date(), timeInputValue, granularity);
|
|
193
|
+
setSelectedDateTime(nextDate);
|
|
194
|
+
setMonth(nextDate);
|
|
195
|
+
setInputValue(formatInputValue(nextDate));
|
|
196
|
+
setIsPickerOpen(false);
|
|
197
|
+
onChange?.(nextDate);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const popupParentRef = useContext(PopupParentContext);
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<div className={classNames('ds-date-time-picker', className)}>
|
|
204
|
+
<div
|
|
205
|
+
className={classNames(
|
|
206
|
+
'ds-date-time-picker__field',
|
|
207
|
+
{ 'ds-date-time-picker__field--show-hint': showFieldHint },
|
|
208
|
+
)}
|
|
209
|
+
>
|
|
210
|
+
<TextInput
|
|
211
|
+
ref={inputRef}
|
|
212
|
+
type={isNative ? 'datetime-local' : 'text'}
|
|
213
|
+
value={inputValue}
|
|
214
|
+
onChange={onInputChange}
|
|
215
|
+
onPointerDown={onNativeFieldHintPointerDown}
|
|
216
|
+
readOnly={hasCustomEmptyHint}
|
|
217
|
+
id={id}
|
|
218
|
+
step={isNative ? (granularity === 'second' ? 1 : 60) : undefined}
|
|
219
|
+
placeholder={showFieldHint ? '' : resolvedPlaceholder}
|
|
220
|
+
hasError={hasError}
|
|
221
|
+
aria-describedby={ariaDescribedBy}
|
|
222
|
+
aria-invalid={ariaInvalid}
|
|
223
|
+
className="ds-date-time-picker__input"
|
|
224
|
+
/>
|
|
225
|
+
{showFieldHint
|
|
226
|
+
? (
|
|
227
|
+
<span className="ds-date-time-picker__field-hint" aria-hidden="true">
|
|
228
|
+
{resolvedPlaceholder}
|
|
229
|
+
</span>
|
|
230
|
+
)
|
|
231
|
+
: null}
|
|
232
|
+
</div>
|
|
233
|
+
<Popover.Root open={isPickerOpen} onOpenChange={open => setIsPickerOpen(open)}>
|
|
234
|
+
<Popover.Trigger asChild>
|
|
235
|
+
<Button
|
|
236
|
+
className="ds-date-time-picker__button"
|
|
237
|
+
variant="text-link"
|
|
238
|
+
iconLeftName="date"
|
|
239
|
+
iconLeftScreenReaderText="Open date and time picker"
|
|
240
|
+
/>
|
|
241
|
+
</Popover.Trigger>
|
|
242
|
+
<Popover.Portal container={popupParentRef.current}>
|
|
243
|
+
<Popover.Content
|
|
244
|
+
align="end"
|
|
245
|
+
sideOffset={5}
|
|
246
|
+
onOpenAutoFocus={e => e.preventDefault()}
|
|
247
|
+
onCloseAutoFocus={(e) => {
|
|
248
|
+
e.preventDefault();
|
|
249
|
+
inputRef.current?.focus();
|
|
250
|
+
}}
|
|
251
|
+
>
|
|
252
|
+
<div className="ds-date-time-picker__popup">
|
|
253
|
+
<TimeInput
|
|
254
|
+
className="ds-date-time-picker__time"
|
|
255
|
+
aria-label="Select time"
|
|
256
|
+
value={timeInputValue as TimeValue | ''}
|
|
257
|
+
onValueChange={handleTimeChange}
|
|
258
|
+
granularity={granularity}
|
|
259
|
+
options={timeOptions}
|
|
260
|
+
searchType={searchType}
|
|
261
|
+
highlightStringMatches={highlightStringMatches}
|
|
262
|
+
hasError={hasError}
|
|
263
|
+
/>
|
|
264
|
+
<DatePickerCalendarHeader month={month} onMonthChange={setMonth} />
|
|
265
|
+
<DayPicker
|
|
266
|
+
className="ds-date-time-picker__calendar"
|
|
267
|
+
month={month}
|
|
268
|
+
onMonthChange={setMonth}
|
|
269
|
+
autoFocus
|
|
270
|
+
role="application"
|
|
271
|
+
mode="single"
|
|
272
|
+
selected={selectedDateTime}
|
|
273
|
+
onSelect={handleDayPickerSelect}
|
|
274
|
+
hideNavigation
|
|
275
|
+
footer={(
|
|
276
|
+
<Button
|
|
277
|
+
variant="text-link"
|
|
278
|
+
onClick={handleTodayClick}
|
|
279
|
+
className="ds-date-time-picker__today-button"
|
|
280
|
+
>
|
|
281
|
+
Today
|
|
282
|
+
</Button>
|
|
283
|
+
)}
|
|
284
|
+
/>
|
|
285
|
+
</div>
|
|
286
|
+
</Popover.Content>
|
|
287
|
+
</Popover.Portal>
|
|
288
|
+
</Popover.Root>
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
export type { DateTimePickerDisplayFormat } from 'Components/datePicker/dateInputUtils';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
@use "../datePicker/date-field-hint" as shell;
|
|
2
|
+
|
|
3
|
+
.ds-date-time-picker {
|
|
4
|
+
position: relative;
|
|
5
|
+
min-width: 0;
|
|
6
|
+
|
|
7
|
+
@include shell.ds-date-picker-shell-field("date-time-picker");
|
|
8
|
+
@include shell.ds-date-picker-shell-button("date-time-picker");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
@include shell.ds-date-picker-shell-popup("date-time-picker");
|
|
12
|
+
|
|
13
|
+
.ds-date-time-picker__time {
|
|
14
|
+
width: 100%;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@include shell.ds-date-picker-shell-calendar("date-time-picker");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { expect, test, describe } from 'vitest';
|
|
2
|
-
import { FormField } from './FormField';
|
|
3
|
-
import { render, screen } from '@testing-library/react';
|
|
4
1
|
import '@testing-library/jest-dom/vitest';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, test } from 'vitest';
|
|
4
|
+
import { FormField } from './FormField';
|
|
5
5
|
|
|
6
6
|
describe('FormField component', () => {
|
|
7
7
|
test('renders a form field', () => {
|
|
@@ -64,8 +64,8 @@ describe('FormField component', () => {
|
|
|
64
64
|
});
|
|
65
65
|
|
|
66
66
|
test('renders a date picker when inputType is datePicker', () => {
|
|
67
|
-
render(<FormField id="niceid" inputType="datePicker" />);
|
|
68
|
-
expect(
|
|
67
|
+
const { container } = render(<FormField id="niceid" inputType="datePicker" />);
|
|
68
|
+
expect(container.querySelector('input[type="date"]')).toBeInTheDocument();
|
|
69
69
|
expect(screen.getByRole('button', { name: 'Open date picker' })).toBeInTheDocument();
|
|
70
70
|
});
|
|
71
71
|
|
|
@@ -182,4 +182,32 @@ describe('SelectDropdown component', () => {
|
|
|
182
182
|
expect(await screen.findByText('Option 1')).toBeInTheDocument();
|
|
183
183
|
expect(screen.getByText('header1')).toBeInTheDocument();
|
|
184
184
|
});
|
|
185
|
+
|
|
186
|
+
test('updates the rendered selection when controlled selectedValues change', () => {
|
|
187
|
+
const { rerender } = render(
|
|
188
|
+
<SelectDropdown
|
|
189
|
+
options={[
|
|
190
|
+
{ label: 'June', value: '5' },
|
|
191
|
+
{ label: 'July', value: '6' },
|
|
192
|
+
]}
|
|
193
|
+
selectedValues={['5']}
|
|
194
|
+
onSelectionChange={vi.fn()}
|
|
195
|
+
/>,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
expect(screen.getByRole('button', { name: /june/i })).toBeInTheDocument();
|
|
199
|
+
|
|
200
|
+
rerender(
|
|
201
|
+
<SelectDropdown
|
|
202
|
+
options={[
|
|
203
|
+
{ label: 'June', value: '5' },
|
|
204
|
+
{ label: 'July', value: '6' },
|
|
205
|
+
]}
|
|
206
|
+
selectedValues={['6']}
|
|
207
|
+
onSelectionChange={vi.fn()}
|
|
208
|
+
/>,
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
expect(screen.getByRole('button', { name: /july/i })).toBeInTheDocument();
|
|
212
|
+
});
|
|
185
213
|
});
|