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