@arbor-education/design-system.components 4.9.2 → 5.1.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 (111) 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.d.ts +1 -0
  24. package/dist/components/formField/FormField.d.ts.map +1 -1
  25. package/dist/components/formField/FormField.js +2 -2
  26. package/dist/components/formField/FormField.js.map +1 -1
  27. package/dist/components/formField/FormField.stories.d.ts +9 -0
  28. package/dist/components/formField/FormField.stories.d.ts.map +1 -1
  29. package/dist/components/formField/FormField.stories.js +28 -16
  30. package/dist/components/formField/FormField.stories.js.map +1 -1
  31. package/dist/components/formField/FormField.test.js +23 -1
  32. package/dist/components/formField/FormField.test.js.map +1 -1
  33. package/dist/components/formField/inputs/textArea/TextArea.d.ts +4 -1
  34. package/dist/components/formField/inputs/textArea/TextArea.d.ts.map +1 -1
  35. package/dist/components/formField/inputs/textArea/TextArea.js +33 -14
  36. package/dist/components/formField/inputs/textArea/TextArea.js.map +1 -1
  37. package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts +6 -1
  38. package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts.map +1 -1
  39. package/dist/components/formField/inputs/textArea/TextArea.stories.js +33 -13
  40. package/dist/components/formField/inputs/textArea/TextArea.stories.js.map +1 -1
  41. package/dist/components/formField/inputs/textArea/TextArea.test.d.ts.map +1 -1
  42. package/dist/components/formField/inputs/textArea/TextArea.test.js +76 -2
  43. package/dist/components/formField/inputs/textArea/TextArea.test.js.map +1 -1
  44. package/dist/components/formField/inputs/time/TimeInput.d.ts.map +1 -1
  45. package/dist/components/formField/inputs/time/TimeInput.js +24 -8
  46. package/dist/components/formField/inputs/time/TimeInput.js.map +1 -1
  47. package/dist/components/formField/inputs/time/TimeInput.stories.d.ts +0 -1
  48. package/dist/components/formField/inputs/time/TimeInput.stories.d.ts.map +1 -1
  49. package/dist/components/formField/inputs/time/TimeInput.stories.js +38 -67
  50. package/dist/components/formField/inputs/time/TimeInput.stories.js.map +1 -1
  51. package/dist/components/formField/inputs/time/TimeInput.test.js +51 -6
  52. package/dist/components/formField/inputs/time/TimeInput.test.js.map +1 -1
  53. package/dist/components/kvpList/KVPList.d.ts +2 -1
  54. package/dist/components/kvpList/KVPList.d.ts.map +1 -1
  55. package/dist/components/kvpList/KVPList.js +1 -1
  56. package/dist/components/kvpList/KVPList.js.map +1 -1
  57. package/dist/components/kvpList/KVPList.stories.d.ts +2 -1
  58. package/dist/components/kvpList/KVPList.stories.d.ts.map +1 -1
  59. package/dist/components/kvpList/KVPList.stories.js +35 -0
  60. package/dist/components/kvpList/KVPList.stories.js.map +1 -1
  61. package/dist/components/kvpList/KVPList.test.js +16 -0
  62. package/dist/components/kvpList/KVPList.test.js.map +1 -1
  63. package/dist/components/row/Row.d.ts +1 -0
  64. package/dist/components/row/Row.d.ts.map +1 -1
  65. package/dist/components/row/Row.js +3 -2
  66. package/dist/components/row/Row.js.map +1 -1
  67. package/dist/components/row/Row.stories.d.ts +13 -0
  68. package/dist/components/row/Row.stories.d.ts.map +1 -1
  69. package/dist/components/row/Row.stories.js +53 -0
  70. package/dist/components/row/Row.stories.js.map +1 -1
  71. package/dist/components/row/Row.test.js +40 -0
  72. package/dist/components/row/Row.test.js.map +1 -1
  73. package/dist/index.css +10 -5
  74. package/dist/index.css.map +1 -1
  75. package/dist/utils/hooks/useIsControlledOnceSet.d.ts +6 -0
  76. package/dist/utils/hooks/useIsControlledOnceSet.d.ts.map +1 -0
  77. package/dist/utils/hooks/useIsControlledOnceSet.js +13 -0
  78. package/dist/utils/hooks/useIsControlledOnceSet.js.map +1 -0
  79. package/dist/utils/hooks/useIsControlledOnceSet.test.d.ts +2 -0
  80. package/dist/utils/hooks/useIsControlledOnceSet.test.d.ts.map +1 -0
  81. package/dist/utils/hooks/useIsControlledOnceSet.test.js +25 -0
  82. package/dist/utils/hooks/useIsControlledOnceSet.test.js.map +1 -0
  83. package/package.json +1 -1
  84. package/src/components/banner/banner.scss +4 -0
  85. package/src/components/datePicker/DatePicker.test.tsx +48 -0
  86. package/src/components/datePicker/DatePicker.tsx +2 -1
  87. package/src/components/datePicker/dateInputUtils.test.ts +29 -0
  88. package/src/components/datePicker/dateInputUtils.ts +24 -0
  89. package/src/components/dateTimePicker/DateTimePicker.stories.tsx +5 -0
  90. package/src/components/dateTimePicker/DateTimePicker.test.tsx +186 -0
  91. package/src/components/dateTimePicker/DateTimePicker.tsx +38 -8
  92. package/src/components/formField/FormField.stories.tsx +32 -18
  93. package/src/components/formField/FormField.test.tsx +28 -1
  94. package/src/components/formField/FormField.tsx +3 -2
  95. package/src/components/formField/inputs/textArea/TextArea.stories.tsx +61 -13
  96. package/src/components/formField/inputs/textArea/TextArea.test.tsx +94 -2
  97. package/src/components/formField/inputs/textArea/TextArea.tsx +39 -13
  98. package/src/components/formField/inputs/time/TimeInput.stories.tsx +38 -87
  99. package/src/components/formField/inputs/time/TimeInput.test.tsx +75 -6
  100. package/src/components/formField/inputs/time/TimeInput.tsx +29 -10
  101. package/src/components/formField/inputs/time/timeInput.scss +0 -6
  102. package/src/components/kvpList/KVPList.stories.tsx +54 -0
  103. package/src/components/kvpList/KVPList.test.tsx +52 -0
  104. package/src/components/kvpList/KVPList.tsx +10 -1
  105. package/src/components/kvpList/kvpList.scss +4 -0
  106. package/src/components/row/Row.stories.tsx +63 -0
  107. package/src/components/row/Row.test.tsx +59 -0
  108. package/src/components/row/Row.tsx +4 -0
  109. package/src/components/row/row.scss +5 -2
  110. package/src/utils/hooks/useIsControlledOnceSet.test.ts +44 -0
  111. package/src/utils/hooks/useIsControlledOnceSet.ts +13 -0
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=useIsControlledOnceSet.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useIsControlledOnceSet.test.d.ts","sourceRoot":"","sources":["../../../src/utils/hooks/useIsControlledOnceSet.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,25 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { renderHook } from '@testing-library/react';
3
+ import { useIsControlledOnceSet } from './useIsControlledOnceSet.js';
4
+ describe('useIsControlledOnceSet', () => {
5
+ test('is uncontrolled while no value has been received', () => {
6
+ const { result, rerender } = renderHook(({ value }) => useIsControlledOnceSet(value), { initialProps: { value: undefined } });
7
+ rerender({ value: undefined });
8
+ expect(result.current).toBe(false);
9
+ });
10
+ test('is controlled from the first render with a value', () => {
11
+ const { result } = renderHook(() => useIsControlledOnceSet(new Date(2026, 3, 14)));
12
+ expect(result.current).toBe(true);
13
+ });
14
+ test('becomes controlled when a value arrives after an empty mount', () => {
15
+ const { result, rerender } = renderHook(({ value }) => useIsControlledOnceSet(value), { initialProps: { value: undefined } });
16
+ rerender({ value: new Date(2026, 3, 14) });
17
+ expect(result.current).toBe(true);
18
+ });
19
+ test('stays controlled when the value is cleared', () => {
20
+ const { result, rerender } = renderHook(({ value }) => useIsControlledOnceSet(value), { initialProps: { value: new Date(2026, 3, 14) } });
21
+ rerender({ value: undefined });
22
+ expect(result.current).toBe(true);
23
+ });
24
+ });
25
+ //# sourceMappingURL=useIsControlledOnceSet.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useIsControlledOnceSet.test.js","sourceRoot":"","sources":["../../../src/utils/hooks/useIsControlledOnceSet.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACpD,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAErE,QAAQ,CAAC,wBAAwB,EAAE,GAAG,EAAE;IACtC,IAAI,CAAC,kDAAkD,EAAE,GAAG,EAAE;QAC5D,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,UAAU,CACrC,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,sBAAsB,CAAC,KAAK,CAAC,EAC5C,EAAE,YAAY,EAAE,EAAE,KAAK,EAAE,SAA6B,EAAE,EAAE,CAC3D,CAAC;QAEF,QAAQ,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC,CAAC;QAE/B,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACrC,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,kDAAkD,EAAE,GAAG,EAAE;QAC5D,MAAM,EAAE,MAAM,EAAE,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,sBAAsB,CAAC,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;QAEnF,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACpC,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,8DAA8D,EAAE,GAAG,EAAE;QACxE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,UAAU,CACrC,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,sBAAsB,CAAC,KAAK,CAAC,EAC5C,EAAE,YAAY,EAAE,EAAE,KAAK,EAAE,SAA6B,EAAE,EAAE,CAC3D,CAAC;QAEF,QAAQ,CAAC,EAAE,KAAK,EAAE,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC,EAAE,CAAC,CAAC;QAE3C,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACpC,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,4CAA4C,EAAE,GAAG,EAAE;QACtD,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,UAAU,CACrC,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,sBAAsB,CAAC,KAAK,CAAC,EAC5C,EAAE,YAAY,EAAE,EAAE,KAAK,EAAE,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE,CAAqB,EAAE,EAAE,CACvE,CAAC;QAEF,QAAQ,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC,CAAC;QAE/B,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACpC,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arbor-education/design-system.components",
3
- "version": "4.9.2",
3
+ "version": "5.1.0",
4
4
  "description": "The component library for the design system (the baby)",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -7,6 +7,10 @@
7
7
  border-width: 1px;
8
8
  border-style: solid;
9
9
 
10
+ > svg {
11
+ flex: none;
12
+ }
13
+
10
14
  &__central-container {
11
15
  flex-grow: 1;
12
16
  display: flex;
@@ -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}
@@ -94,9 +94,10 @@ const DEVELOPER_NOTES = [
94
94
  '**7. Always provide `label` when using `helperLinkText`.** The helper link `aria-label` is',
95
95
  'constructed as `"${label} helper link"`. Omit `label` and the aria-label becomes `"undefined helper link"`.',
96
96
  '',
97
- '**8. Optional fields: use the label suffix convention.** There is no `required` prop and no',
98
- 'asterisk pattern. Mark optional fields by appending `"(optional)"` to the label text, e.g.',
99
- '`label="Middle name (optional)"`. All fields without this suffix are implicitly required.',
97
+ '**8. Optional fields: pass `optional`.** FormField appends `"(optional)"` to the label for you,',
98
+ 'e.g. `label="Middle name" optional` renders "Middle name (optional)". There is no asterisk pattern.',
99
+ 'Omit `optional` and the label is rendered untouched. `optional` only changes the label;',
100
+ 'pass native `required` via `inputProps` for form validation and assistive technology semantics.',
100
101
  '',
101
102
  '**9. `NumberInput` renders `type="text"` internally.** Do not pass `type` in `inputProps` when',
102
103
  '`inputType="number"` — it is ignored and may produce confusing behaviour. The component uses',
@@ -226,14 +227,23 @@ const meta = {
226
227
  control: 'text',
227
228
  description: [
228
229
  'Visible label rendered above the input via the `Label` component (`ds-label`).',
229
- 'Short, sentence case. Use `"Field name (optional)"` suffix for optional fields — there is',
230
- 'no `required` prop and no asterisk convention.',
230
+ 'Short, sentence case. Do not add an `"(optional)"` suffix yourself — pass `optional`.',
231
231
  '**Also required when using `helperLinkText`** — the link\'s `aria-label` uses this value verbatim.',
232
232
  ].join(' '),
233
233
  table: {
234
234
  type: { summary: 'string' },
235
235
  },
236
236
  },
237
+ optional: {
238
+ control: 'boolean',
239
+ description: [
240
+ 'Controls the label treatment. `true` appends `"(optional)"` to the label; `false` or omitted leaves',
241
+ 'the label untouched. Does not affect the native `required` attribute — use `inputProps.required` for that.',
242
+ ].join(' '),
243
+ table: {
244
+ type: { summary: 'boolean' },
245
+ },
246
+ },
237
247
  inputType: {
238
248
  control: 'select',
239
249
  options: ['text', 'textarea', 'number', 'time', 'colourPicker', 'selectDropdown', 'datePicker', 'combobox', 'checkbox', 'checkboxGroup', 'toggle', 'radio', 'singleFile', 'custom'],
@@ -815,13 +825,12 @@ export default TextAreaExample;
815
825
  ),
816
826
  },
817
827
  [
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`.',
828
+ '`inputType="textarea"` renders a `TextArea` — a multi-line input that sizes itself to its content',
829
+ '(`autoSize={true}` is the default). The `rows` prop in `inputProps` sets the minimum starting height.',
821
830
  '',
822
831
  '**Use for:** pastoral notes, medical conditions, free-text feedback, long descriptions.',
823
832
  '',
824
- '**Auto-sizing:** the textarea expands vertically as the user types — it does not scroll.',
833
+ '**Auto-sizing:** the textarea fits its content on mount, when the controlled `value` changes, and as the user types — it does not scroll.',
825
834
  'To opt out, pass `autoSize: false` in `inputProps`.',
826
835
  '',
827
836
  '**All shared wiring still applies:** `errorText`, `fieldDescription`, and `id` work identically.',
@@ -1291,17 +1300,19 @@ function OptionalAndRequiredFieldsExample() {
1291
1300
  />
1292
1301
  <FormField
1293
1302
  id="student-middle-name"
1294
- label="Middle name (optional)"
1303
+ label="Middle name"
1304
+ optional
1295
1305
  inputProps={{ placeholder: 'e.g. Marie' }}
1296
1306
  />
1297
1307
  <FormField
1298
1308
  id="student-surname"
1299
1309
  label="Surname"
1300
- inputProps={{ placeholder: 'e.g. Nylund' }}
1310
+ inputProps={{ placeholder: 'e.g. Nylund', required: true }}
1301
1311
  />
1302
1312
  <FormField
1303
1313
  id="student-preferred-name"
1304
- label="Preferred name (optional)"
1314
+ label="Preferred name"
1315
+ optional
1305
1316
  fieldDescription="Used in class registers and parent communications"
1306
1317
  inputProps={{ placeholder: 'e.g. Rosie' }}
1307
1318
  />
@@ -1328,17 +1339,19 @@ export default OptionalAndRequiredFieldsExample;
1328
1339
  />
1329
1340
  <FormField
1330
1341
  id="student-middle-name"
1331
- label="Middle name (optional)"
1342
+ label="Middle name"
1343
+ optional
1332
1344
  inputProps={{ placeholder: 'e.g. Marie' }}
1333
1345
  />
1334
1346
  <FormField
1335
1347
  id="student-surname"
1336
1348
  label="Surname"
1337
- inputProps={{ placeholder: 'e.g. Nylund' }}
1349
+ inputProps={{ placeholder: 'e.g. Nylund', required: true }}
1338
1350
  />
1339
1351
  <FormField
1340
1352
  id="student-preferred-name"
1341
- label="Preferred name (optional)"
1353
+ label="Preferred name"
1354
+ optional
1342
1355
  fieldDescription="Used in class registers and parent communications"
1343
1356
  inputProps={{ placeholder: 'e.g. Rosie' }}
1344
1357
  />
@@ -1352,10 +1365,11 @@ export default OptionalAndRequiredFieldsExample;
1352
1365
  ),
1353
1366
  },
1354
1367
  [
1355
- 'Arbor uses the **label suffix convention** for optional fields — no asterisk, no `required` prop.',
1356
- 'Append `"(optional)"` directly to the label text: `label="Middle name (optional)"`.',
1368
+ 'Arbor uses the **label suffix convention** for optional fields — no asterisk.',
1369
+ 'Pass `optional` and FormField appends `"(optional)"` to the label: `label="Middle name" optional`.',
1357
1370
  '',
1358
- 'Fields without this suffix are implicitly required. This is a deliberate Confluence design',
1371
+ 'Fields rendered without `optional` carry no suffix and are implicitly required. Pass native',
1372
+ '`required` via `inputProps` as well so form validation and assistive technology agree. This is a deliberate Confluence design',
1359
1373
  'decision: marking optional fields (the minority) is less visually noisy than marking required',
1360
1374
  'fields (the majority) with an asterisk that users learn to ignore anyway.',
1361
1375
  '',