@arbor-education/design-system.components 4.9.1 → 5.0.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/CHANGELOG.md +26 -0
- package/dist/components/datePicker/DatePicker.d.ts.map +1 -1
- package/dist/components/datePicker/DatePicker.js +2 -1
- package/dist/components/datePicker/DatePicker.js.map +1 -1
- package/dist/components/datePicker/DatePicker.test.js +32 -0
- package/dist/components/datePicker/DatePicker.test.js.map +1 -1
- package/dist/components/datePicker/dateInputUtils.d.ts +2 -1
- package/dist/components/datePicker/dateInputUtils.d.ts.map +1 -1
- package/dist/components/datePicker/dateInputUtils.js +20 -0
- package/dist/components/datePicker/dateInputUtils.js.map +1 -1
- package/dist/components/datePicker/dateInputUtils.test.d.ts +2 -0
- package/dist/components/datePicker/dateInputUtils.test.d.ts.map +1 -0
- package/dist/components/datePicker/dateInputUtils.test.js +28 -0
- package/dist/components/datePicker/dateInputUtils.test.js.map +1 -0
- package/dist/components/dateTimePicker/DateTimePicker.d.ts.map +1 -1
- package/dist/components/dateTimePicker/DateTimePicker.js +34 -10
- package/dist/components/dateTimePicker/DateTimePicker.js.map +1 -1
- package/dist/components/dateTimePicker/DateTimePicker.stories.d.ts.map +1 -1
- package/dist/components/dateTimePicker/DateTimePicker.stories.js +5 -0
- package/dist/components/dateTimePicker/DateTimePicker.stories.js.map +1 -1
- package/dist/components/dateTimePicker/DateTimePicker.test.js +136 -0
- package/dist/components/dateTimePicker/DateTimePicker.test.js.map +1 -1
- package/dist/components/formField/FormField.stories.d.ts.map +1 -1
- package/dist/components/formField/FormField.stories.js +3 -4
- package/dist/components/formField/FormField.stories.js.map +1 -1
- package/dist/components/formField/FormField.test.js +1 -1
- package/dist/components/formField/FormField.test.js.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.d.ts +4 -1
- package/dist/components/formField/inputs/textArea/TextArea.d.ts.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.js +33 -14
- package/dist/components/formField/inputs/textArea/TextArea.js.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts +6 -1
- package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.stories.js +33 -13
- package/dist/components/formField/inputs/textArea/TextArea.stories.js.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.test.d.ts.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.test.js +76 -2
- package/dist/components/formField/inputs/textArea/TextArea.test.js.map +1 -1
- package/dist/components/formField/inputs/time/TimeInput.d.ts.map +1 -1
- package/dist/components/formField/inputs/time/TimeInput.js +24 -8
- package/dist/components/formField/inputs/time/TimeInput.js.map +1 -1
- package/dist/components/formField/inputs/time/TimeInput.stories.d.ts +0 -1
- package/dist/components/formField/inputs/time/TimeInput.stories.d.ts.map +1 -1
- package/dist/components/formField/inputs/time/TimeInput.stories.js +38 -67
- package/dist/components/formField/inputs/time/TimeInput.stories.js.map +1 -1
- package/dist/components/formField/inputs/time/TimeInput.test.js +51 -6
- package/dist/components/formField/inputs/time/TimeInput.test.js.map +1 -1
- package/dist/components/kvpList/KVPList.d.ts +2 -1
- package/dist/components/kvpList/KVPList.d.ts.map +1 -1
- package/dist/components/kvpList/KVPList.js +1 -1
- package/dist/components/kvpList/KVPList.js.map +1 -1
- package/dist/components/kvpList/KVPList.stories.d.ts +2 -1
- package/dist/components/kvpList/KVPList.stories.d.ts.map +1 -1
- package/dist/components/kvpList/KVPList.stories.js +35 -0
- package/dist/components/kvpList/KVPList.stories.js.map +1 -1
- package/dist/components/kvpList/KVPList.test.js +16 -0
- package/dist/components/kvpList/KVPList.test.js.map +1 -1
- package/dist/components/row/Row.d.ts +1 -0
- package/dist/components/row/Row.d.ts.map +1 -1
- package/dist/components/row/Row.js +3 -2
- package/dist/components/row/Row.js.map +1 -1
- package/dist/components/row/Row.stories.d.ts +13 -0
- package/dist/components/row/Row.stories.d.ts.map +1 -1
- package/dist/components/row/Row.stories.js +53 -0
- package/dist/components/row/Row.stories.js.map +1 -1
- package/dist/components/row/Row.test.js +40 -0
- package/dist/components/row/Row.test.js.map +1 -1
- package/dist/components/table/cellEditors/NumberCellEditor.d.ts +2 -2
- package/dist/components/table/cellEditors/NumberCellEditor.d.ts.map +1 -1
- package/dist/components/table/cellEditors/NumberCellEditor.js +1 -1
- package/dist/components/table/cellEditors/NumberCellEditor.js.map +1 -1
- package/dist/components/table/cellEditors/NumberCellEditor.test.d.ts +2 -0
- package/dist/components/table/cellEditors/NumberCellEditor.test.d.ts.map +1 -0
- package/dist/components/table/cellEditors/NumberCellEditor.test.js +57 -0
- package/dist/components/table/cellEditors/NumberCellEditor.test.js.map +1 -0
- package/dist/index.css +10 -5
- package/dist/index.css.map +1 -1
- package/dist/utils/hooks/useIsControlledOnceSet.d.ts +6 -0
- package/dist/utils/hooks/useIsControlledOnceSet.d.ts.map +1 -0
- package/dist/utils/hooks/useIsControlledOnceSet.js +13 -0
- package/dist/utils/hooks/useIsControlledOnceSet.js.map +1 -0
- package/dist/utils/hooks/useIsControlledOnceSet.test.d.ts +2 -0
- package/dist/utils/hooks/useIsControlledOnceSet.test.d.ts.map +1 -0
- package/dist/utils/hooks/useIsControlledOnceSet.test.js +25 -0
- package/dist/utils/hooks/useIsControlledOnceSet.test.js.map +1 -0
- package/package.json +1 -1
- package/src/components/banner/banner.scss +4 -0
- package/src/components/datePicker/DatePicker.test.tsx +48 -0
- package/src/components/datePicker/DatePicker.tsx +2 -1
- package/src/components/datePicker/dateInputUtils.test.ts +29 -0
- package/src/components/datePicker/dateInputUtils.ts +24 -0
- package/src/components/dateTimePicker/DateTimePicker.stories.tsx +5 -0
- package/src/components/dateTimePicker/DateTimePicker.test.tsx +186 -0
- package/src/components/dateTimePicker/DateTimePicker.tsx +38 -8
- package/src/components/formField/FormField.stories.tsx +3 -4
- package/src/components/formField/FormField.test.tsx +1 -1
- package/src/components/formField/inputs/textArea/TextArea.stories.tsx +61 -13
- package/src/components/formField/inputs/textArea/TextArea.test.tsx +94 -2
- package/src/components/formField/inputs/textArea/TextArea.tsx +39 -13
- package/src/components/formField/inputs/time/TimeInput.stories.tsx +38 -87
- package/src/components/formField/inputs/time/TimeInput.test.tsx +75 -6
- package/src/components/formField/inputs/time/TimeInput.tsx +29 -10
- package/src/components/formField/inputs/time/timeInput.scss +0 -6
- package/src/components/kvpList/KVPList.stories.tsx +54 -0
- package/src/components/kvpList/KVPList.test.tsx +52 -0
- package/src/components/kvpList/KVPList.tsx +10 -1
- package/src/components/kvpList/kvpList.scss +4 -0
- package/src/components/row/Row.stories.tsx +63 -0
- package/src/components/row/Row.test.tsx +59 -0
- package/src/components/row/Row.tsx +4 -0
- package/src/components/row/row.scss +5 -2
- package/src/components/table/cellEditors/NumberCellEditor.test.tsx +74 -0
- package/src/components/table/cellEditors/NumberCellEditor.tsx +4 -4
- package/src/utils/hooks/useIsControlledOnceSet.test.ts +44 -0
- package/src/utils/hooks/useIsControlledOnceSet.ts +13 -0
|
@@ -223,6 +223,15 @@ describe('DatePicker', () => {
|
|
|
223
223
|
render(<DatePicker defaultValue={new Date(2024, 5, 15)} onChange={onChange} />);
|
|
224
224
|
expect(onChange).not.toHaveBeenCalled();
|
|
225
225
|
});
|
|
226
|
+
|
|
227
|
+
test('keeps the user-entered value across re-renders', () => {
|
|
228
|
+
const { rerender } = render(<DatePicker aria-label="Start date" defaultValue={new Date(2024, 5, 15)} />);
|
|
229
|
+
|
|
230
|
+
fireEvent.change(screen.getByLabelText('Start date'), { target: { value: '2024-12-25' } });
|
|
231
|
+
rerender(<DatePicker aria-label="Start date" defaultValue={new Date(2024, 5, 15)} />);
|
|
232
|
+
|
|
233
|
+
expect(screen.getByLabelText('Start date')).toHaveValue('2024-12-25');
|
|
234
|
+
});
|
|
226
235
|
});
|
|
227
236
|
|
|
228
237
|
describe('onChange call-count correctness', () => {
|
|
@@ -285,5 +294,44 @@ describe('DatePicker', () => {
|
|
|
285
294
|
const lastDate: Date = onChange.mock.lastCall![0];
|
|
286
295
|
expect(lastDate.getDate()).toBe(20);
|
|
287
296
|
});
|
|
297
|
+
|
|
298
|
+
test('clearing the value empties the input and calendar selection without firing onChange', async () => {
|
|
299
|
+
const onChange = vi.fn();
|
|
300
|
+
const { rerender } = render(
|
|
301
|
+
<DatePicker aria-label="Start date" value={new Date(2024, 5, 15)} onChange={onChange} />,
|
|
302
|
+
);
|
|
303
|
+
await userEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
|
304
|
+
const calendar = screen.getByRole('application');
|
|
305
|
+
expect(within(calendar).getByRole('gridcell', { selected: true })).toBeInTheDocument();
|
|
306
|
+
onChange.mockClear();
|
|
307
|
+
|
|
308
|
+
rerender(<DatePicker aria-label="Start date" value={undefined} onChange={onChange} />);
|
|
309
|
+
|
|
310
|
+
expect(screen.getByLabelText('Start date')).toHaveValue('');
|
|
311
|
+
expect(within(calendar).queryByRole('gridcell', { selected: true })).not.toBeInTheDocument();
|
|
312
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('a value that starts empty displays a value supplied later', () => {
|
|
316
|
+
const { rerender } = render(<DatePicker aria-label="Start date" value={undefined} />);
|
|
317
|
+
|
|
318
|
+
rerender(<DatePicker aria-label="Start date" value={new Date(2024, 11, 25)} />);
|
|
319
|
+
|
|
320
|
+
expect(screen.getByLabelText('Start date')).toHaveValue(formatNativeDateInputValue(new Date(2024, 11, 25)));
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('a cleared value still accepts a new date from the user', () => {
|
|
324
|
+
const onChange = vi.fn();
|
|
325
|
+
const { rerender } = render(
|
|
326
|
+
<DatePicker aria-label="Start date" value={new Date(2024, 5, 15)} onChange={onChange} />,
|
|
327
|
+
);
|
|
328
|
+
rerender(<DatePicker aria-label="Start date" value={undefined} onChange={onChange} />);
|
|
329
|
+
|
|
330
|
+
fireEvent.change(screen.getByLabelText('Start date'), { target: { value: '2024-12-25' } });
|
|
331
|
+
|
|
332
|
+
expect(screen.getByLabelText('Start date')).toHaveValue('2024-12-25');
|
|
333
|
+
const lastDate: Date = onChange.mock.lastCall![0];
|
|
334
|
+
expect([lastDate.getFullYear(), lastDate.getMonth(), lastDate.getDate()]).toEqual([2024, 11, 25]);
|
|
335
|
+
});
|
|
288
336
|
});
|
|
289
337
|
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type ChangeEvent,
|
|
12
12
|
type PointerEvent,
|
|
13
13
|
} from 'react';
|
|
14
|
+
import { useIsControlledOnceSet } from 'Utils/hooks/useIsControlledOnceSet';
|
|
14
15
|
import { PopupParentContext } from 'Utils/popupParent/PopupParentContext';
|
|
15
16
|
import {
|
|
16
17
|
formatNativeDateInputValue,
|
|
@@ -59,7 +60,7 @@ export const DatePicker = (props: DatePickerProps) => {
|
|
|
59
60
|
} = props;
|
|
60
61
|
|
|
61
62
|
const initialValue = value ?? defaultValue;
|
|
62
|
-
const isControlled = value
|
|
63
|
+
const isControlled = useIsControlledOnceSet(value);
|
|
63
64
|
const [month, setMonth] = useState(initialValue ?? new Date());
|
|
64
65
|
const [selectedDate, setSelectedDate] = useState<Date | undefined>(
|
|
65
66
|
initialValue,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { parseCompleteTime } from './dateInputUtils.js';
|
|
3
|
+
|
|
4
|
+
describe('parseCompleteTime', () => {
|
|
5
|
+
test.each([
|
|
6
|
+
['14:30', 'minute', '14:30'],
|
|
7
|
+
['9:05', 'minute', '09:05'],
|
|
8
|
+
['00:00', 'minute', '00:00'],
|
|
9
|
+
['23:59', 'minute', '23:59'],
|
|
10
|
+
['14:30', 'second', '14:30:00'],
|
|
11
|
+
['14:30:15', 'second', '14:30:15'],
|
|
12
|
+
] as const)('normalises %s at %s granularity to %s', (value, granularity, expected) => {
|
|
13
|
+
expect(parseCompleteTime(value, granularity)).toBe(expected);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test.each([
|
|
17
|
+
['', 'minute'],
|
|
18
|
+
['905', 'minute'],
|
|
19
|
+
['9', 'minute'],
|
|
20
|
+
['14:', 'minute'],
|
|
21
|
+
['14:3', 'minute'],
|
|
22
|
+
['25:00', 'minute'],
|
|
23
|
+
['14:75', 'minute'],
|
|
24
|
+
['14:30:15', 'minute'],
|
|
25
|
+
['14:30:60', 'second'],
|
|
26
|
+
] as const)('rejects partial, out-of-range or wrong-precision %s at %s granularity', (value, granularity) => {
|
|
27
|
+
expect(parseCompleteTime(value, granularity)).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -105,6 +105,30 @@ export const formatTimeInputValue = (value: Date, granularity: TimeGranularity):
|
|
|
105
105
|
format(value, getTimeFormatString(granularity)) as TimeValue
|
|
106
106
|
);
|
|
107
107
|
|
|
108
|
+
const COMPLETE_TIME_PATTERN = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/;
|
|
109
|
+
const MAX_HOURS = 23;
|
|
110
|
+
const MAX_MINUTES_OR_SECONDS = 59;
|
|
111
|
+
|
|
112
|
+
export const parseCompleteTime = (value: string, granularity: TimeGranularity): string | null => {
|
|
113
|
+
const match = COMPLETE_TIME_PATTERN.exec(value);
|
|
114
|
+
if (!match) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const [, rawHours = '', minutes = '', rawSeconds] = match;
|
|
119
|
+
const hours = Number(rawHours);
|
|
120
|
+
const seconds = rawSeconds ?? '00';
|
|
121
|
+
if (granularity === 'minute' && rawSeconds !== undefined) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
if (hours > MAX_HOURS || Number(minutes) > MAX_MINUTES_OR_SECONDS || Number(seconds) > MAX_MINUTES_OR_SECONDS) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const paddedTime = `${String(hours).padStart(2, '0')}:${minutes}`;
|
|
129
|
+
return granularity === 'second' ? `${paddedTime}:${seconds}` : paddedTime;
|
|
130
|
+
};
|
|
131
|
+
|
|
108
132
|
export const mergeDateAndTime = (date: Date, timeValue: string, granularity: TimeGranularity): Date => {
|
|
109
133
|
const [rawHours = '0', rawMinutes = '0', rawSeconds = '0'] = timeValue.split(':');
|
|
110
134
|
const hours = Number.parseInt(rawHours, 10) || 0;
|
|
@@ -77,6 +77,11 @@ const DEVELOPER_NOTES = [
|
|
|
77
77
|
'**`timeOptions` switches the embedded time input to combobox mode.** Instead of a free-form',
|
|
78
78
|
'time input, the user picks from a predefined list. Use for fixed appointment slots.',
|
|
79
79
|
'',
|
|
80
|
+
'**Typed times commit on blur, Enter or closing the popup.** In free-form mode the embedded time',
|
|
81
|
+
'input holds what the user types, and the date changes only once a complete, in-range time is',
|
|
82
|
+
'committed. Partial or out-of-range text never changes the date. In combobox mode, choosing an',
|
|
83
|
+
'option commits immediately.',
|
|
84
|
+
'',
|
|
80
85
|
'**`hasError` is visual-only when used standalone.** It applies the red border but does NOT',
|
|
81
86
|
'set `aria-invalid`. Always pair both when outside `<FormField>`:',
|
|
82
87
|
'',
|
|
@@ -188,6 +188,142 @@ describe('DateTimePicker', () => {
|
|
|
188
188
|
expect(lastDate.getMinutes()).toBe(30);
|
|
189
189
|
});
|
|
190
190
|
|
|
191
|
+
test('partial text typed into the popup time field does not change the date', async () => {
|
|
192
|
+
const user = userEvent.setup();
|
|
193
|
+
const onChange = vi.fn();
|
|
194
|
+
|
|
195
|
+
render(<DateTimePicker value={new Date(2024, 5, 1, 14, 30)} onChange={onChange} />);
|
|
196
|
+
onChange.mockClear();
|
|
197
|
+
|
|
198
|
+
await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
|
|
199
|
+
const timeInput = screen.getByLabelText('Select time', { selector: 'input' });
|
|
200
|
+
await user.clear(timeInput);
|
|
201
|
+
await user.type(timeInput, '14');
|
|
202
|
+
|
|
203
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('out-of-range text in the popup time field does not change the date on Enter', async () => {
|
|
207
|
+
const user = userEvent.setup();
|
|
208
|
+
const onChange = vi.fn();
|
|
209
|
+
|
|
210
|
+
render(<DateTimePicker value={new Date(2024, 5, 1, 14, 30)} onChange={onChange} />);
|
|
211
|
+
onChange.mockClear();
|
|
212
|
+
|
|
213
|
+
await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
|
|
214
|
+
const timeInput = screen.getByLabelText('Select time', { selector: 'input' });
|
|
215
|
+
await user.clear(timeInput);
|
|
216
|
+
await user.type(timeInput, '25:00{Enter}');
|
|
217
|
+
|
|
218
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('typing in the popup time field does not emit until the time is committed', async () => {
|
|
222
|
+
const user = userEvent.setup();
|
|
223
|
+
const onChange = vi.fn();
|
|
224
|
+
|
|
225
|
+
render(<DateTimePicker value={new Date(2024, 5, 1, 9, 0)} onChange={onChange} />);
|
|
226
|
+
onChange.mockClear();
|
|
227
|
+
|
|
228
|
+
await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
|
|
229
|
+
const timeInput = screen.getByLabelText('Select time', { selector: 'input' });
|
|
230
|
+
await user.clear(timeInput);
|
|
231
|
+
await user.type(timeInput, '14:30');
|
|
232
|
+
|
|
233
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
234
|
+
expect(timeInput).toHaveValue('14:30');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('Enter in the popup time field commits the typed time once', async () => {
|
|
238
|
+
const user = userEvent.setup();
|
|
239
|
+
const onChange = vi.fn();
|
|
240
|
+
|
|
241
|
+
render(<DateTimePicker value={new Date(2024, 5, 1, 9, 0)} onChange={onChange} />);
|
|
242
|
+
onChange.mockClear();
|
|
243
|
+
|
|
244
|
+
await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
|
|
245
|
+
const timeInput = screen.getByLabelText('Select time', { selector: 'input' });
|
|
246
|
+
await user.clear(timeInput);
|
|
247
|
+
await user.type(timeInput, '14:30{Enter}');
|
|
248
|
+
|
|
249
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
250
|
+
const emitted: Date = onChange.mock.lastCall![0];
|
|
251
|
+
expect(emitted.getDate()).toBe(1);
|
|
252
|
+
expect(emitted.getHours()).toBe(14);
|
|
253
|
+
expect(emitted.getMinutes()).toBe(30);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('leaving the popup time field commits the typed time', async () => {
|
|
257
|
+
const user = userEvent.setup();
|
|
258
|
+
const onChange = vi.fn();
|
|
259
|
+
|
|
260
|
+
render(<DateTimePicker value={new Date(2024, 5, 1, 9, 0)} onChange={onChange} />);
|
|
261
|
+
onChange.mockClear();
|
|
262
|
+
|
|
263
|
+
await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
|
|
264
|
+
const timeInput = screen.getByLabelText('Select time', { selector: 'input' });
|
|
265
|
+
await user.clear(timeInput);
|
|
266
|
+
await user.type(timeInput, '14:30');
|
|
267
|
+
await user.tab();
|
|
268
|
+
|
|
269
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
270
|
+
const emitted: Date = onChange.mock.lastCall![0];
|
|
271
|
+
expect(emitted.getHours()).toBe(14);
|
|
272
|
+
expect(emitted.getMinutes()).toBe(30);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('closing the popup with Escape commits the typed time', async () => {
|
|
276
|
+
const user = userEvent.setup();
|
|
277
|
+
const onChange = vi.fn();
|
|
278
|
+
|
|
279
|
+
render(<DateTimePicker value={new Date(2024, 5, 1, 9, 0)} onChange={onChange} />);
|
|
280
|
+
onChange.mockClear();
|
|
281
|
+
|
|
282
|
+
await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
|
|
283
|
+
const timeInput = screen.getByLabelText('Select time', { selector: 'input' });
|
|
284
|
+
await user.clear(timeInput);
|
|
285
|
+
await user.type(timeInput, '14:30');
|
|
286
|
+
await user.keyboard('{Escape}');
|
|
287
|
+
|
|
288
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
289
|
+
const emitted: Date = onChange.mock.lastCall![0];
|
|
290
|
+
expect(emitted.getHours()).toBe(14);
|
|
291
|
+
expect(emitted.getMinutes()).toBe(30);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('a partial time that fails to commit is replaced with the selected time on blur', async () => {
|
|
295
|
+
const user = userEvent.setup();
|
|
296
|
+
|
|
297
|
+
render(<DateTimePicker value={new Date(2024, 5, 1, 14, 30)} />);
|
|
298
|
+
|
|
299
|
+
await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
|
|
300
|
+
const timeInput = screen.getByLabelText('Select time', { selector: 'input' });
|
|
301
|
+
await user.clear(timeInput);
|
|
302
|
+
await user.type(timeInput, '14');
|
|
303
|
+
await user.tab();
|
|
304
|
+
|
|
305
|
+
expect(timeInput).toHaveValue('14:30');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('picking a day keeps the selected time when the popup time text is partial', async () => {
|
|
309
|
+
const user = userEvent.setup();
|
|
310
|
+
const onChange = vi.fn();
|
|
311
|
+
|
|
312
|
+
render(<DateTimePicker value={new Date(2024, 5, 1, 9, 15)} onChange={onChange} />);
|
|
313
|
+
onChange.mockClear();
|
|
314
|
+
|
|
315
|
+
await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
|
|
316
|
+
const timeInput = screen.getByLabelText('Select time', { selector: 'input' });
|
|
317
|
+
await user.clear(timeInput);
|
|
318
|
+
await user.type(timeInput, '14');
|
|
319
|
+
await user.click(within(screen.getByRole('application')).getByRole('button', { name: /June 20/ }));
|
|
320
|
+
|
|
321
|
+
const emitted: Date = onChange.mock.lastCall![0];
|
|
322
|
+
expect(emitted.getDate()).toBe(20);
|
|
323
|
+
expect(emitted.getHours()).toBe(9);
|
|
324
|
+
expect(emitted.getMinutes()).toBe(15);
|
|
325
|
+
});
|
|
326
|
+
|
|
191
327
|
test('opening the picker after typing a valid combined value syncs month, year, and time controls', async () => {
|
|
192
328
|
const user = userEvent.setup();
|
|
193
329
|
const { container } = render(<DateTimePicker />);
|
|
@@ -245,6 +381,17 @@ describe('DateTimePicker', () => {
|
|
|
245
381
|
render(<DateTimePicker defaultValue={new Date(2024, 5, 15, 14, 30)} onChange={onChange} />);
|
|
246
382
|
expect(onChange).not.toHaveBeenCalled();
|
|
247
383
|
});
|
|
384
|
+
|
|
385
|
+
test('keeps the user-entered value across re-renders', () => {
|
|
386
|
+
const { rerender } = render(
|
|
387
|
+
<DateTimePicker aria-label="Start" defaultValue={new Date(2024, 5, 15, 14, 30)} />,
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
fireEvent.change(screen.getByLabelText('Start'), { target: { value: '2024-12-25T09:45' } });
|
|
391
|
+
rerender(<DateTimePicker aria-label="Start" defaultValue={new Date(2024, 5, 15, 14, 30)} />);
|
|
392
|
+
|
|
393
|
+
expect(screen.getByLabelText('Start')).toHaveValue('2024-12-25T09:45');
|
|
394
|
+
});
|
|
248
395
|
});
|
|
249
396
|
|
|
250
397
|
describe('onChange call-count correctness', () => {
|
|
@@ -289,6 +436,45 @@ describe('DateTimePicker', () => {
|
|
|
289
436
|
expect(onChange).not.toHaveBeenCalled();
|
|
290
437
|
});
|
|
291
438
|
|
|
439
|
+
test('clearing a controlled value empties the date, time and calendar selection without firing onChange', () => {
|
|
440
|
+
const onChange = vi.fn();
|
|
441
|
+
const { rerender } = render(
|
|
442
|
+
<DateTimePicker aria-label="Start" value={new Date(2026, 3, 14, 14, 27)} onChange={onChange} />,
|
|
443
|
+
);
|
|
444
|
+
fireEvent.click(screen.getByRole('button', { name: 'Open date and time picker' }));
|
|
445
|
+
const calendar = screen.getByRole('application');
|
|
446
|
+
expect(within(calendar).getByRole('gridcell', { selected: true })).toBeInTheDocument();
|
|
447
|
+
onChange.mockClear();
|
|
448
|
+
|
|
449
|
+
rerender(<DateTimePicker aria-label="Start" value={undefined} onChange={onChange} />);
|
|
450
|
+
|
|
451
|
+
expect(screen.getByLabelText('Start')).toHaveValue('');
|
|
452
|
+
expect(screen.getByLabelText('Select time', { selector: 'input' })).toHaveValue('');
|
|
453
|
+
expect(within(calendar).queryByRole('gridcell', { selected: true })).not.toBeInTheDocument();
|
|
454
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('a cleared controlled picker still accepts a new date from the user', () => {
|
|
458
|
+
const onChange = vi.fn();
|
|
459
|
+
const { rerender } = render(
|
|
460
|
+
<DateTimePicker aria-label="Start" value={new Date(2026, 3, 14, 14, 27)} onChange={onChange} />,
|
|
461
|
+
);
|
|
462
|
+
rerender(<DateTimePicker aria-label="Start" value={undefined} onChange={onChange} />);
|
|
463
|
+
|
|
464
|
+
fireEvent.change(screen.getByLabelText('Start'), { target: { value: '2026-05-01T08:15' } });
|
|
465
|
+
|
|
466
|
+
expect(screen.getByLabelText('Start')).toHaveValue('2026-05-01T08:15');
|
|
467
|
+
expect(onChange).toHaveBeenLastCalledWith(new Date(2026, 4, 1, 8, 15));
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test('a controlled value that starts empty displays a value supplied later', () => {
|
|
471
|
+
const { rerender } = render(<DateTimePicker aria-label="Start" value={undefined} />);
|
|
472
|
+
|
|
473
|
+
rerender(<DateTimePicker aria-label="Start" value={new Date(2026, 3, 14, 14, 27)} />);
|
|
474
|
+
|
|
475
|
+
expect(screen.getByLabelText('Start')).toHaveValue('2026-04-14T14:27');
|
|
476
|
+
});
|
|
477
|
+
|
|
292
478
|
test('Today preserves the current time and closes the picker', async () => {
|
|
293
479
|
const user = userEvent.setup();
|
|
294
480
|
const onChange = vi.fn();
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getDateTimePlaceholder,
|
|
10
10
|
getNativeDateTimePlaceholder,
|
|
11
11
|
mergeDateAndTime,
|
|
12
|
+
parseCompleteTime,
|
|
12
13
|
parseDateTimeInputValue,
|
|
13
14
|
parseNativeDateTimeInputValue,
|
|
14
15
|
type DateDisplayFormat,
|
|
@@ -30,8 +31,10 @@ import {
|
|
|
30
31
|
useRef,
|
|
31
32
|
useState,
|
|
32
33
|
type ChangeEvent,
|
|
34
|
+
type KeyboardEvent,
|
|
33
35
|
type PointerEvent,
|
|
34
36
|
} from 'react';
|
|
37
|
+
import { useIsControlledOnceSet } from 'Utils/hooks/useIsControlledOnceSet';
|
|
35
38
|
import { PopupParentContext } from 'Utils/popupParent/PopupParentContext';
|
|
36
39
|
|
|
37
40
|
export type DateTimePickerProps = {
|
|
@@ -109,7 +112,7 @@ export const DateTimePicker = (props: DateTimePickerProps) => {
|
|
|
109
112
|
));
|
|
110
113
|
|
|
111
114
|
const initialValue = value ?? defaultValue;
|
|
112
|
-
const isControlled = value
|
|
115
|
+
const isControlled = useIsControlledOnceSet(value);
|
|
113
116
|
const [month, setMonth] = useState(initialValue ?? new Date());
|
|
114
117
|
const [selectedDateTime, setSelectedDateTime] = useState<Date | undefined>(
|
|
115
118
|
initialValue,
|
|
@@ -189,16 +192,23 @@ export const DateTimePicker = (props: DateTimePickerProps) => {
|
|
|
189
192
|
}
|
|
190
193
|
};
|
|
191
194
|
|
|
192
|
-
const
|
|
193
|
-
setTimeInputValue(nextTimeValue);
|
|
195
|
+
const selectedTimeText = selectedDateTime ? formatTimeInputValue(selectedDateTime, granularity) : '';
|
|
194
196
|
|
|
195
|
-
|
|
197
|
+
const commitTimeInput = (nextTimeValue: string) => {
|
|
198
|
+
const completeTime = parseCompleteTime(nextTimeValue, granularity);
|
|
199
|
+
if (completeTime === null) {
|
|
200
|
+
if (selectedDateTime) {
|
|
201
|
+
setTimeInputValue(selectedTimeText);
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (!selectedDateTime || completeTime === selectedTimeText) {
|
|
196
206
|
return;
|
|
197
207
|
}
|
|
198
208
|
|
|
199
209
|
const nextDateTime = mergeDateAndTime(
|
|
200
210
|
selectedDateTime,
|
|
201
|
-
|
|
211
|
+
completeTime,
|
|
202
212
|
granularity,
|
|
203
213
|
);
|
|
204
214
|
setSelectedDateTime(nextDateTime);
|
|
@@ -206,6 +216,19 @@ export const DateTimePicker = (props: DateTimePickerProps) => {
|
|
|
206
216
|
onChange?.(nextDateTime);
|
|
207
217
|
};
|
|
208
218
|
|
|
219
|
+
const handleTimeChange = (nextTimeValue: string) => {
|
|
220
|
+
setTimeInputValue(nextTimeValue);
|
|
221
|
+
if (timeOptions) {
|
|
222
|
+
commitTimeInput(nextTimeValue);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const handleTimeInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
|
227
|
+
if (event.key === 'Enter') {
|
|
228
|
+
commitTimeInput(timeInputValue);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
209
232
|
const handleDayPickerSelect = (nextDate?: Date) => {
|
|
210
233
|
if (!nextDate) {
|
|
211
234
|
setSelectedDateTime(undefined);
|
|
@@ -217,7 +240,7 @@ export const DateTimePicker = (props: DateTimePickerProps) => {
|
|
|
217
240
|
|
|
218
241
|
const nextDateTime = mergeDateAndTime(
|
|
219
242
|
nextDate,
|
|
220
|
-
timeInputValue,
|
|
243
|
+
parseCompleteTime(timeInputValue, granularity) ?? selectedTimeText,
|
|
221
244
|
granularity,
|
|
222
245
|
);
|
|
223
246
|
setSelectedDateTime(nextDateTime);
|
|
@@ -230,7 +253,7 @@ export const DateTimePicker = (props: DateTimePickerProps) => {
|
|
|
230
253
|
};
|
|
231
254
|
|
|
232
255
|
const handleTodayClick = () => {
|
|
233
|
-
const nextDate = mergeDateAndTime(new Date(), timeInputValue, granularity);
|
|
256
|
+
const nextDate = mergeDateAndTime(new Date(), parseCompleteTime(timeInputValue, granularity) ?? selectedTimeText, granularity);
|
|
234
257
|
setSelectedDateTime(nextDate);
|
|
235
258
|
setMonth(nextDate);
|
|
236
259
|
setInputValue(formatInputValue(nextDate));
|
|
@@ -276,7 +299,12 @@ export const DateTimePicker = (props: DateTimePickerProps) => {
|
|
|
276
299
|
</div>
|
|
277
300
|
<Popover.Root
|
|
278
301
|
open={isPickerOpen}
|
|
279
|
-
onOpenChange={open =>
|
|
302
|
+
onOpenChange={(open) => {
|
|
303
|
+
if (!open) {
|
|
304
|
+
commitTimeInput(timeInputValue);
|
|
305
|
+
}
|
|
306
|
+
setIsPickerOpen(open);
|
|
307
|
+
}}
|
|
280
308
|
>
|
|
281
309
|
<Popover.Trigger asChild>
|
|
282
310
|
<Button
|
|
@@ -303,6 +331,8 @@ export const DateTimePicker = (props: DateTimePickerProps) => {
|
|
|
303
331
|
aria-label="Select time"
|
|
304
332
|
value={timeInputValue as TimeValue | ''}
|
|
305
333
|
onValueChange={handleTimeChange}
|
|
334
|
+
onBlur={() => commitTimeInput(timeInputValue)}
|
|
335
|
+
onKeyDown={handleTimeInputKeyDown}
|
|
306
336
|
granularity={granularity}
|
|
307
337
|
options={timeOptions}
|
|
308
338
|
searchType={searchType}
|
|
@@ -815,13 +815,12 @@ export default TextAreaExample;
|
|
|
815
815
|
),
|
|
816
816
|
},
|
|
817
817
|
[
|
|
818
|
-
'`inputType="textarea"` renders a `TextArea` — a multi-line input that
|
|
819
|
-
'
|
|
820
|
-
'`rows` prop in `inputProps`.',
|
|
818
|
+
'`inputType="textarea"` renders a `TextArea` — a multi-line input that sizes itself to its content',
|
|
819
|
+
'(`autoSize={true}` is the default). The `rows` prop in `inputProps` sets the minimum starting height.',
|
|
821
820
|
'',
|
|
822
821
|
'**Use for:** pastoral notes, medical conditions, free-text feedback, long descriptions.',
|
|
823
822
|
'',
|
|
824
|
-
'**Auto-sizing:** the textarea
|
|
823
|
+
'**Auto-sizing:** the textarea fits its content on mount, when the controlled `value` changes, and as the user types — it does not scroll.',
|
|
825
824
|
'To opt out, pass `autoSize: false` in `inputProps`.',
|
|
826
825
|
'',
|
|
827
826
|
'**All shared wiring still applies:** `errorText`, `fieldDescription`, and `id` work identically.',
|
|
@@ -48,7 +48,7 @@ describe('FormField component', () => {
|
|
|
48
48
|
test('renders a time input when inputType is time', () => {
|
|
49
49
|
render(<FormField id="niceid" inputType="time" inputProps={{ 'aria-label': 'Start time' }} />);
|
|
50
50
|
const input = screen.getByLabelText('Start time');
|
|
51
|
-
expect(input).
|
|
51
|
+
expect(input).toHaveClass('ds-time-input__input');
|
|
52
52
|
});
|
|
53
53
|
|
|
54
54
|
test('renders a colour picker dropdown when inputType is colourPicker', () => {
|
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
Title,
|
|
10
10
|
} from '@storybook/addon-docs/blocks';
|
|
11
11
|
import { useState } from 'react';
|
|
12
|
-
import { fn } from 'storybook/test';
|
|
12
|
+
import { expect, fn, within } from 'storybook/test';
|
|
13
|
+
import { Button } from 'Components/button/Button';
|
|
13
14
|
import { FormField } from 'Components/formField/FormField';
|
|
14
15
|
import { TextArea } from './TextArea.js';
|
|
15
16
|
|
|
@@ -57,11 +58,11 @@ const DEVELOPER_NOTES = [
|
|
|
57
58
|
'### Critical gotchas',
|
|
58
59
|
'',
|
|
59
60
|
'#### 1. `autoSize` mechanics',
|
|
60
|
-
'When `autoSize={true}` (the default), the component reads `scrollHeight`
|
|
61
|
-
'
|
|
62
|
-
'
|
|
63
|
-
'
|
|
64
|
-
'
|
|
61
|
+
'When `autoSize={true}` (the default), the component reads `scrollHeight` and sets',
|
|
62
|
+
'`height = scrollHeight + 4px` (vertical padding compensation).',
|
|
63
|
+
'It does this on mount, whenever the controlled `value` changes, and on every `onChange` event,',
|
|
64
|
+
'so a field pre-populated through `value` or `defaultValue` opens fitted to its content.',
|
|
65
|
+
'`rows` is the minimum starting height for empty or short content.',
|
|
65
66
|
'',
|
|
66
67
|
'#### 2. CSS `resize` is NOT controlled by this component',
|
|
67
68
|
'The browser default (`resize: both` or `resize: vertical` depending on browser) applies.',
|
|
@@ -75,11 +76,9 @@ const DEVELOPER_NOTES = [
|
|
|
75
76
|
'<TextArea className="my-fixed-textarea" />',
|
|
76
77
|
'```',
|
|
77
78
|
'',
|
|
78
|
-
'#### 3.
|
|
79
|
-
'TextArea
|
|
80
|
-
'
|
|
81
|
-
'If you need imperative focus control, wrap the TextArea and use a separate DOM query,',
|
|
82
|
-
'or request `forwardRef` support as a library enhancement.',
|
|
79
|
+
'#### 3. `ref` resolves to the native `<textarea>`',
|
|
80
|
+
'TextArea is a `forwardRef` component. A `ref` you pass resolves to the underlying `HTMLTextAreaElement`,',
|
|
81
|
+
'for example to call `focus()` or `select()`. Attaching a ref does not interfere with `autoSize`.',
|
|
83
82
|
'',
|
|
84
83
|
'#### 4. `size` prop is intentionally omitted',
|
|
85
84
|
'The HTML `size` attribute is omitted from the props (the TypeScript type uses `Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "size">`).',
|
|
@@ -277,8 +276,7 @@ const meta = {
|
|
|
277
276
|
description: [
|
|
278
277
|
'Uncontrolled initial value. The textarea manages its own state after mount.',
|
|
279
278
|
'Do not combine with `value` — use one or the other.',
|
|
280
|
-
'
|
|
281
|
-
'The textarea starts at the height set by `rows` regardless of the initial value length.',
|
|
279
|
+
'With `autoSize` enabled, the textarea grows on mount to fit a long `defaultValue`.',
|
|
282
280
|
].join(' '),
|
|
283
281
|
table: {
|
|
284
282
|
type: { summary: 'string' },
|
|
@@ -642,6 +640,56 @@ export const ReadOnly: Story = withDescription(
|
|
|
642
640
|
].join(' '),
|
|
643
641
|
);
|
|
644
642
|
|
|
643
|
+
const PRE_POPULATED_NOTES = 'Pupil has a documented nut allergy (anaphylaxis risk). EpiPen is held in the school office and a second pen travels with the pupil on trips. All catering staff have been briefed. Parents have supplied an updated care plan, which is reviewed each term with the school nurse.';
|
|
644
|
+
|
|
645
|
+
export const PrePopulated: Story = withDescription(
|
|
646
|
+
{
|
|
647
|
+
render: () => (
|
|
648
|
+
<div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-xsmall)' }}>
|
|
649
|
+
<label htmlFor="medical-notes-prepopulated" style={{ color: 'var(--color-grey-600)' }}>
|
|
650
|
+
Medical notes
|
|
651
|
+
</label>
|
|
652
|
+
<TextArea id="medical-notes-prepopulated" rows={2} defaultValue={PRE_POPULATED_NOTES} />
|
|
653
|
+
</div>
|
|
654
|
+
),
|
|
655
|
+
play: async ({ canvasElement }) => {
|
|
656
|
+
const canvas = within(canvasElement);
|
|
657
|
+
const textArea = canvas.getByRole('textbox', { name: 'Medical notes' });
|
|
658
|
+
|
|
659
|
+
await expect(textArea.scrollHeight).toBeLessThanOrEqual(textArea.clientHeight);
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
[
|
|
663
|
+
'A field opened with stored content longer than its `rows` height.',
|
|
664
|
+
'With `autoSize` enabled (the default) the textarea fits the content on mount, so nothing is hidden behind an internal scrollbar.',
|
|
665
|
+
].join(' '),
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
const ExternallyUpdatedValueTemplate = () => {
|
|
669
|
+
const [notes, setNotes] = useState('Allergy reviewed.');
|
|
670
|
+
return (
|
|
671
|
+
<div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-xsmall)' }}>
|
|
672
|
+
<label htmlFor="care-plan-notes" style={{ color: 'var(--color-grey-600)' }}>
|
|
673
|
+
Care plan notes
|
|
674
|
+
</label>
|
|
675
|
+
<TextArea id="care-plan-notes" rows={2} value={notes} onChange={e => setNotes(e.currentTarget.value)} />
|
|
676
|
+
<Button variant="secondary" size="S" onClick={() => setNotes(PRE_POPULATED_NOTES)}>
|
|
677
|
+
Load care plan template
|
|
678
|
+
</Button>
|
|
679
|
+
</div>
|
|
680
|
+
);
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
export const ExternallyUpdatedValue: Story = withDescription(
|
|
684
|
+
{
|
|
685
|
+
render: () => <ExternallyUpdatedValueTemplate />,
|
|
686
|
+
},
|
|
687
|
+
[
|
|
688
|
+
'A controlled textarea whose `value` is replaced from outside, here by loading a template.',
|
|
689
|
+
'With `autoSize` enabled the height refits to the new content without the user typing.',
|
|
690
|
+
].join(' '),
|
|
691
|
+
);
|
|
692
|
+
|
|
645
693
|
export const WithFormField: Story = withDescription(
|
|
646
694
|
{
|
|
647
695
|
render: () => (
|