@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.
Files changed (115) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/components/datePicker/DatePicker.d.ts.map +1 -1
  3. package/dist/components/datePicker/DatePicker.js +2 -1
  4. package/dist/components/datePicker/DatePicker.js.map +1 -1
  5. package/dist/components/datePicker/DatePicker.test.js +32 -0
  6. package/dist/components/datePicker/DatePicker.test.js.map +1 -1
  7. package/dist/components/datePicker/dateInputUtils.d.ts +2 -1
  8. package/dist/components/datePicker/dateInputUtils.d.ts.map +1 -1
  9. package/dist/components/datePicker/dateInputUtils.js +20 -0
  10. package/dist/components/datePicker/dateInputUtils.js.map +1 -1
  11. package/dist/components/datePicker/dateInputUtils.test.d.ts +2 -0
  12. package/dist/components/datePicker/dateInputUtils.test.d.ts.map +1 -0
  13. package/dist/components/datePicker/dateInputUtils.test.js +28 -0
  14. package/dist/components/datePicker/dateInputUtils.test.js.map +1 -0
  15. package/dist/components/dateTimePicker/DateTimePicker.d.ts.map +1 -1
  16. package/dist/components/dateTimePicker/DateTimePicker.js +34 -10
  17. package/dist/components/dateTimePicker/DateTimePicker.js.map +1 -1
  18. package/dist/components/dateTimePicker/DateTimePicker.stories.d.ts.map +1 -1
  19. package/dist/components/dateTimePicker/DateTimePicker.stories.js +5 -0
  20. package/dist/components/dateTimePicker/DateTimePicker.stories.js.map +1 -1
  21. package/dist/components/dateTimePicker/DateTimePicker.test.js +136 -0
  22. package/dist/components/dateTimePicker/DateTimePicker.test.js.map +1 -1
  23. package/dist/components/formField/FormField.stories.d.ts.map +1 -1
  24. package/dist/components/formField/FormField.stories.js +3 -4
  25. package/dist/components/formField/FormField.stories.js.map +1 -1
  26. package/dist/components/formField/FormField.test.js +1 -1
  27. package/dist/components/formField/FormField.test.js.map +1 -1
  28. package/dist/components/formField/inputs/textArea/TextArea.d.ts +4 -1
  29. package/dist/components/formField/inputs/textArea/TextArea.d.ts.map +1 -1
  30. package/dist/components/formField/inputs/textArea/TextArea.js +33 -14
  31. package/dist/components/formField/inputs/textArea/TextArea.js.map +1 -1
  32. package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts +6 -1
  33. package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts.map +1 -1
  34. package/dist/components/formField/inputs/textArea/TextArea.stories.js +33 -13
  35. package/dist/components/formField/inputs/textArea/TextArea.stories.js.map +1 -1
  36. package/dist/components/formField/inputs/textArea/TextArea.test.d.ts.map +1 -1
  37. package/dist/components/formField/inputs/textArea/TextArea.test.js +76 -2
  38. package/dist/components/formField/inputs/textArea/TextArea.test.js.map +1 -1
  39. package/dist/components/formField/inputs/time/TimeInput.d.ts.map +1 -1
  40. package/dist/components/formField/inputs/time/TimeInput.js +24 -8
  41. package/dist/components/formField/inputs/time/TimeInput.js.map +1 -1
  42. package/dist/components/formField/inputs/time/TimeInput.stories.d.ts +0 -1
  43. package/dist/components/formField/inputs/time/TimeInput.stories.d.ts.map +1 -1
  44. package/dist/components/formField/inputs/time/TimeInput.stories.js +38 -67
  45. package/dist/components/formField/inputs/time/TimeInput.stories.js.map +1 -1
  46. package/dist/components/formField/inputs/time/TimeInput.test.js +51 -6
  47. package/dist/components/formField/inputs/time/TimeInput.test.js.map +1 -1
  48. package/dist/components/kvpList/KVPList.d.ts +2 -1
  49. package/dist/components/kvpList/KVPList.d.ts.map +1 -1
  50. package/dist/components/kvpList/KVPList.js +1 -1
  51. package/dist/components/kvpList/KVPList.js.map +1 -1
  52. package/dist/components/kvpList/KVPList.stories.d.ts +2 -1
  53. package/dist/components/kvpList/KVPList.stories.d.ts.map +1 -1
  54. package/dist/components/kvpList/KVPList.stories.js +35 -0
  55. package/dist/components/kvpList/KVPList.stories.js.map +1 -1
  56. package/dist/components/kvpList/KVPList.test.js +16 -0
  57. package/dist/components/kvpList/KVPList.test.js.map +1 -1
  58. package/dist/components/row/Row.d.ts +1 -0
  59. package/dist/components/row/Row.d.ts.map +1 -1
  60. package/dist/components/row/Row.js +3 -2
  61. package/dist/components/row/Row.js.map +1 -1
  62. package/dist/components/row/Row.stories.d.ts +13 -0
  63. package/dist/components/row/Row.stories.d.ts.map +1 -1
  64. package/dist/components/row/Row.stories.js +53 -0
  65. package/dist/components/row/Row.stories.js.map +1 -1
  66. package/dist/components/row/Row.test.js +40 -0
  67. package/dist/components/row/Row.test.js.map +1 -1
  68. package/dist/components/table/cellEditors/NumberCellEditor.d.ts +2 -2
  69. package/dist/components/table/cellEditors/NumberCellEditor.d.ts.map +1 -1
  70. package/dist/components/table/cellEditors/NumberCellEditor.js +1 -1
  71. package/dist/components/table/cellEditors/NumberCellEditor.js.map +1 -1
  72. package/dist/components/table/cellEditors/NumberCellEditor.test.d.ts +2 -0
  73. package/dist/components/table/cellEditors/NumberCellEditor.test.d.ts.map +1 -0
  74. package/dist/components/table/cellEditors/NumberCellEditor.test.js +57 -0
  75. package/dist/components/table/cellEditors/NumberCellEditor.test.js.map +1 -0
  76. package/dist/index.css +10 -5
  77. package/dist/index.css.map +1 -1
  78. package/dist/utils/hooks/useIsControlledOnceSet.d.ts +6 -0
  79. package/dist/utils/hooks/useIsControlledOnceSet.d.ts.map +1 -0
  80. package/dist/utils/hooks/useIsControlledOnceSet.js +13 -0
  81. package/dist/utils/hooks/useIsControlledOnceSet.js.map +1 -0
  82. package/dist/utils/hooks/useIsControlledOnceSet.test.d.ts +2 -0
  83. package/dist/utils/hooks/useIsControlledOnceSet.test.d.ts.map +1 -0
  84. package/dist/utils/hooks/useIsControlledOnceSet.test.js +25 -0
  85. package/dist/utils/hooks/useIsControlledOnceSet.test.js.map +1 -0
  86. package/package.json +1 -1
  87. package/src/components/banner/banner.scss +4 -0
  88. package/src/components/datePicker/DatePicker.test.tsx +48 -0
  89. package/src/components/datePicker/DatePicker.tsx +2 -1
  90. package/src/components/datePicker/dateInputUtils.test.ts +29 -0
  91. package/src/components/datePicker/dateInputUtils.ts +24 -0
  92. package/src/components/dateTimePicker/DateTimePicker.stories.tsx +5 -0
  93. package/src/components/dateTimePicker/DateTimePicker.test.tsx +186 -0
  94. package/src/components/dateTimePicker/DateTimePicker.tsx +38 -8
  95. package/src/components/formField/FormField.stories.tsx +3 -4
  96. package/src/components/formField/FormField.test.tsx +1 -1
  97. package/src/components/formField/inputs/textArea/TextArea.stories.tsx +61 -13
  98. package/src/components/formField/inputs/textArea/TextArea.test.tsx +94 -2
  99. package/src/components/formField/inputs/textArea/TextArea.tsx +39 -13
  100. package/src/components/formField/inputs/time/TimeInput.stories.tsx +38 -87
  101. package/src/components/formField/inputs/time/TimeInput.test.tsx +75 -6
  102. package/src/components/formField/inputs/time/TimeInput.tsx +29 -10
  103. package/src/components/formField/inputs/time/timeInput.scss +0 -6
  104. package/src/components/kvpList/KVPList.stories.tsx +54 -0
  105. package/src/components/kvpList/KVPList.test.tsx +52 -0
  106. package/src/components/kvpList/KVPList.tsx +10 -1
  107. package/src/components/kvpList/kvpList.scss +4 -0
  108. package/src/components/row/Row.stories.tsx +63 -0
  109. package/src/components/row/Row.test.tsx +59 -0
  110. package/src/components/row/Row.tsx +4 -0
  111. package/src/components/row/row.scss +5 -2
  112. package/src/components/table/cellEditors/NumberCellEditor.test.tsx +74 -0
  113. package/src/components/table/cellEditors/NumberCellEditor.tsx +4 -4
  114. package/src/utils/hooks/useIsControlledOnceSet.test.ts +44 -0
  115. 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 !== undefined;
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 !== undefined;
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 handleTimeChange = (nextTimeValue: string) => {
193
- setTimeInputValue(nextTimeValue);
195
+ const selectedTimeText = selectedDateTime ? formatTimeInputValue(selectedDateTime, granularity) : '';
194
196
 
195
- if (!selectedDateTime) {
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
- nextTimeValue,
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 => setIsPickerOpen(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 grows automatically as',
819
- 'the user types (`autoSize={true}` is the default). The initial height is controlled by the',
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 expands vertically as the user types — it does not scroll.',
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).toHaveAttribute('type', 'time');
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` on every `onChange` event',
61
- 'and sets `height = scrollHeight + 4px` (vertical padding compensation).',
62
- 'The textarea still starts at the height implied by the `rows` prop — growth begins after the first keystroke.',
63
- 'Importantly, `autoSize` is triggered by `onChange`, which means it only fires when the user types.',
64
- 'Setting a large `defaultValue` at mount will NOT auto-grow; call layout imperatively if needed.',
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. No `forwardRef` — consumers cannot attach a ref',
79
- 'TextArea uses an internal `ref` for `autoSize` calculations.',
80
- 'It is NOT a `forwardRef` component — you cannot attach a `ref` to it from the outside.',
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
- 'Note: setting a large `defaultValue` at mount will NOT trigger `autoSize`.',
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: () => (