@arbor-education/design-system.components 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/.github/workflows/release.yml +1 -1
  2. package/CHANGELOG.md +22 -0
  3. package/dist/components/button/Button.d.ts.map +1 -1
  4. package/dist/components/button/Button.js +2 -2
  5. package/dist/components/button/Button.js.map +1 -1
  6. package/dist/components/combobox/Combobox.d.ts.map +1 -1
  7. package/dist/components/combobox/Combobox.js +10 -8
  8. package/dist/components/combobox/Combobox.js.map +1 -1
  9. package/dist/components/combobox/Combobox.stories.d.ts +1 -0
  10. package/dist/components/combobox/Combobox.stories.d.ts.map +1 -1
  11. package/dist/components/combobox/Combobox.stories.js +16 -0
  12. package/dist/components/combobox/Combobox.stories.js.map +1 -1
  13. package/dist/components/combobox/Combobox.test.js +107 -61
  14. package/dist/components/combobox/Combobox.test.js.map +1 -1
  15. package/dist/components/combobox/ComboboxButtonTrigger.d.ts +4 -2
  16. package/dist/components/combobox/ComboboxButtonTrigger.d.ts.map +1 -1
  17. package/dist/components/combobox/ComboboxButtonTrigger.js +11 -4
  18. package/dist/components/combobox/ComboboxButtonTrigger.js.map +1 -1
  19. package/dist/components/combobox/ComboboxTrigger.d.ts +3 -1
  20. package/dist/components/combobox/ComboboxTrigger.d.ts.map +1 -1
  21. package/dist/components/combobox/ComboboxTrigger.js +10 -2
  22. package/dist/components/combobox/ComboboxTrigger.js.map +1 -1
  23. package/dist/components/combobox/types.d.ts +3 -0
  24. package/dist/components/combobox/types.d.ts.map +1 -1
  25. package/dist/components/combobox/useComboboxPopoverBehavior.d.ts +3 -1
  26. package/dist/components/combobox/useComboboxPopoverBehavior.d.ts.map +1 -1
  27. package/dist/components/combobox/useComboboxPopoverBehavior.js +7 -6
  28. package/dist/components/combobox/useComboboxPopoverBehavior.js.map +1 -1
  29. package/dist/components/combobox/useComboboxState.d.ts.map +1 -1
  30. package/dist/components/combobox/useComboboxState.js +4 -1
  31. package/dist/components/combobox/useComboboxState.js.map +1 -1
  32. package/dist/components/datePicker/DatePicker.d.ts +4 -1
  33. package/dist/components/datePicker/DatePicker.d.ts.map +1 -1
  34. package/dist/components/datePicker/DatePicker.js +77 -37
  35. package/dist/components/datePicker/DatePicker.js.map +1 -1
  36. package/dist/components/datePicker/DatePicker.stories.d.ts +28 -3
  37. package/dist/components/datePicker/DatePicker.stories.d.ts.map +1 -1
  38. package/dist/components/datePicker/DatePicker.stories.js +62 -9
  39. package/dist/components/datePicker/DatePicker.stories.js.map +1 -1
  40. package/dist/components/datePicker/DatePicker.test.js +133 -66
  41. package/dist/components/datePicker/DatePicker.test.js.map +1 -1
  42. package/dist/components/datePicker/DatePickerCalendarHeader.d.ts +8 -0
  43. package/dist/components/datePicker/DatePickerCalendarHeader.d.ts.map +1 -0
  44. package/dist/components/datePicker/DatePickerCalendarHeader.js +36 -0
  45. package/dist/components/datePicker/DatePickerCalendarHeader.js.map +1 -0
  46. package/dist/components/datePicker/dateInputUtils.d.ts +25 -0
  47. package/dist/components/datePicker/dateInputUtils.d.ts.map +1 -0
  48. package/dist/components/datePicker/dateInputUtils.js +60 -0
  49. package/dist/components/datePicker/dateInputUtils.js.map +1 -0
  50. package/dist/components/datePicker/datePickerTestUtils.test-helpers.d.ts +2 -0
  51. package/dist/components/datePicker/datePickerTestUtils.test-helpers.d.ts.map +1 -0
  52. package/dist/components/datePicker/datePickerTestUtils.test-helpers.js +4 -0
  53. package/dist/components/datePicker/datePickerTestUtils.test-helpers.js.map +1 -0
  54. package/dist/components/dateTimePicker/DateTimePicker.d.ts +22 -0
  55. package/dist/components/dateTimePicker/DateTimePicker.d.ts.map +1 -0
  56. package/dist/components/dateTimePicker/DateTimePicker.js +132 -0
  57. package/dist/components/dateTimePicker/DateTimePicker.js.map +1 -0
  58. package/dist/components/dateTimePicker/DateTimePicker.stories.d.ts +77 -0
  59. package/dist/components/dateTimePicker/DateTimePicker.stories.d.ts.map +1 -0
  60. package/dist/components/dateTimePicker/DateTimePicker.stories.js +163 -0
  61. package/dist/components/dateTimePicker/DateTimePicker.stories.js.map +1 -0
  62. package/dist/components/dateTimePicker/DateTimePicker.test.d.ts +2 -0
  63. package/dist/components/dateTimePicker/DateTimePicker.test.d.ts.map +1 -0
  64. package/dist/components/dateTimePicker/DateTimePicker.test.js +235 -0
  65. package/dist/components/dateTimePicker/DateTimePicker.test.js.map +1 -0
  66. package/dist/components/formField/FormField.d.ts +4 -0
  67. package/dist/components/formField/FormField.d.ts.map +1 -1
  68. package/dist/components/formField/FormField.js +2 -1
  69. package/dist/components/formField/FormField.js.map +1 -1
  70. package/dist/components/formField/FormField.stories.d.ts.map +1 -1
  71. package/dist/components/formField/FormField.stories.js +4 -1
  72. package/dist/components/formField/FormField.stories.js.map +1 -1
  73. package/dist/components/formField/FormField.test.d.ts.map +1 -1
  74. package/dist/components/formField/FormField.test.js +10 -5
  75. package/dist/components/formField/FormField.test.js.map +1 -1
  76. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.d.ts +1 -0
  77. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.d.ts.map +1 -1
  78. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.js +7 -3
  79. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.js.map +1 -1
  80. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.test.js +12 -0
  81. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.test.js.map +1 -1
  82. package/dist/components/formField/inputs/text/TextInput.d.ts +4 -1
  83. package/dist/components/formField/inputs/text/TextInput.d.ts.map +1 -1
  84. package/dist/components/formField/inputs/text/TextInput.js +5 -4
  85. package/dist/components/formField/inputs/text/TextInput.js.map +1 -1
  86. package/dist/components/formField/inputs/text/TextInput.stories.d.ts +4 -1
  87. package/dist/components/formField/inputs/text/TextInput.stories.d.ts.map +1 -1
  88. package/dist/components/formField/inputs/time/TimeInput.d.ts +29 -0
  89. package/dist/components/formField/inputs/time/TimeInput.d.ts.map +1 -0
  90. package/dist/components/formField/inputs/time/TimeInput.js +67 -0
  91. package/dist/components/formField/inputs/time/TimeInput.js.map +1 -0
  92. package/dist/components/formField/inputs/time/TimeInput.stories.d.ts +60 -0
  93. package/dist/components/formField/inputs/time/TimeInput.stories.d.ts.map +1 -0
  94. package/dist/components/formField/inputs/time/TimeInput.stories.js +132 -0
  95. package/dist/components/formField/inputs/time/TimeInput.stories.js.map +1 -0
  96. package/dist/components/formField/inputs/time/TimeInput.test.d.ts +2 -0
  97. package/dist/components/formField/inputs/time/TimeInput.test.d.ts.map +1 -0
  98. package/dist/components/formField/inputs/time/TimeInput.test.js +58 -0
  99. package/dist/components/formField/inputs/time/TimeInput.test.js.map +1 -0
  100. package/dist/components/table/Table.d.ts.map +1 -1
  101. package/dist/components/table/Table.js +2 -0
  102. package/dist/components/table/Table.js.map +1 -1
  103. package/dist/components/table/Table.stories.d.ts +1 -0
  104. package/dist/components/table/Table.stories.d.ts.map +1 -1
  105. package/dist/components/table/Table.stories.js +37 -0
  106. package/dist/components/table/Table.stories.js.map +1 -1
  107. package/dist/components/table/cellRenderers/BooleanCellRenderer.d.ts +3 -0
  108. package/dist/components/table/cellRenderers/BooleanCellRenderer.d.ts.map +1 -0
  109. package/dist/components/table/cellRenderers/BooleanCellRenderer.js +15 -0
  110. package/dist/components/table/cellRenderers/BooleanCellRenderer.js.map +1 -0
  111. package/dist/components/table/cellRenderers/BooleanCellRenderer.test.d.ts +2 -0
  112. package/dist/components/table/cellRenderers/BooleanCellRenderer.test.d.ts.map +1 -0
  113. package/dist/components/table/cellRenderers/BooleanCellRenderer.test.js +31 -0
  114. package/dist/components/table/cellRenderers/BooleanCellRenderer.test.js.map +1 -0
  115. package/dist/index.css +309 -4
  116. package/dist/index.css.map +1 -1
  117. package/dist/index.d.ts +5 -0
  118. package/dist/index.d.ts.map +1 -1
  119. package/dist/index.js +3 -0
  120. package/dist/index.js.map +1 -1
  121. package/package.json +1 -1
  122. package/src/components/button/Button.tsx +2 -1
  123. package/src/components/combobox/Combobox.stories.tsx +18 -0
  124. package/src/components/combobox/Combobox.test.tsx +131 -61
  125. package/src/components/combobox/Combobox.tsx +15 -6
  126. package/src/components/combobox/ComboboxButtonTrigger.tsx +54 -25
  127. package/src/components/combobox/ComboboxTrigger.tsx +39 -15
  128. package/src/components/combobox/combobox.scss +18 -0
  129. package/src/components/combobox/types.ts +3 -0
  130. package/src/components/combobox/useComboboxPopoverBehavior.ts +10 -5
  131. package/src/components/combobox/useComboboxState.ts +4 -1
  132. package/src/components/datePicker/DatePicker.stories.tsx +67 -9
  133. package/src/components/datePicker/DatePicker.test.tsx +157 -72
  134. package/src/components/datePicker/DatePicker.tsx +163 -69
  135. package/src/components/datePicker/DatePickerCalendarHeader.tsx +82 -0
  136. package/src/components/datePicker/date-field-hint.scss +152 -0
  137. package/src/components/datePicker/dateInputUtils.ts +117 -0
  138. package/src/components/datePicker/datePicker.scss +53 -29
  139. package/src/components/datePicker/datePickerTestUtils.test-helpers.ts +6 -0
  140. package/src/components/dateTimePicker/DateTimePicker.stories.tsx +202 -0
  141. package/src/components/dateTimePicker/DateTimePicker.test.tsx +295 -0
  142. package/src/components/dateTimePicker/DateTimePicker.tsx +293 -0
  143. package/src/components/dateTimePicker/dateTimePicker.scss +17 -0
  144. package/src/components/formField/FormField.stories.tsx +10 -1
  145. package/src/components/formField/FormField.test.tsx +11 -5
  146. package/src/components/formField/FormField.tsx +5 -0
  147. package/src/components/formField/inputs/selectDropdown/SelectDropdown.test.tsx +28 -0
  148. package/src/components/formField/inputs/selectDropdown/SelectDropdown.tsx +8 -2
  149. package/src/components/formField/inputs/text/TextInput.tsx +6 -3
  150. package/src/components/formField/inputs/time/TimeInput.stories.tsx +170 -0
  151. package/src/components/formField/inputs/time/TimeInput.test.tsx +86 -0
  152. package/src/components/formField/inputs/time/TimeInput.tsx +168 -0
  153. package/src/components/formField/inputs/time/timeInput.scss +33 -0
  154. package/src/components/row/row.scss +2 -2
  155. package/src/components/table/Table.stories.tsx +48 -0
  156. package/src/components/table/Table.tsx +2 -0
  157. package/src/components/table/cellRenderers/BooleanCellRenderer.test.tsx +37 -0
  158. package/src/components/table/cellRenderers/BooleanCellRenderer.tsx +34 -0
  159. package/src/components/table/cellRenderers/booleanCellRenderer.scss +7 -0
  160. package/src/index.scss +3 -0
  161. package/src/index.ts +5 -0
@@ -1,37 +1,61 @@
1
+ @use "./date-field-hint" as shell;
2
+
1
3
  .ds-date-picker {
2
4
  position: relative;
5
+ min-width: 0;
6
+
7
+ @include shell.ds-date-picker-shell-field("date-picker");
8
+ @include shell.ds-date-picker-shell-button("date-picker");
9
+ }
10
+
11
+ @include shell.ds-date-picker-shell-popup("date-picker");
3
12
 
4
- .ds-date-picker__button {
5
- position: absolute;
6
- right: 0;
7
- top: 0;
13
+ .ds-date-picker-calendar-header {
14
+ display: flex;
15
+ align-items: center;
16
+ justify-content: space-between;
17
+ gap: var(--spacing-medium);
18
+ }
19
+
20
+ .ds-date-picker-calendar-header__selectors {
21
+ display: flex;
22
+ gap: var(--spacing-small);
8
23
 
9
- svg {
10
- stroke: var(--form-field-icon-default-color-icon);
11
- }
24
+ > * {
25
+ min-width: 0;
26
+ flex: 1 1 0;
12
27
  }
13
28
  }
14
29
 
15
- .ds-date-picker__popup {
16
- box-shadow: var(--shadow-small);
17
- padding: var(--date-picker-spacing-vertical) var(--date-picker-spacing-horizontal);
18
- border-radius: var(--date-picker-radius);
19
- background-color: var(--date-picker-color-background);
20
-
21
- --rdp-accent-color: var(--date-picker-date-cell-today-default-color-text);
22
- --rdp-accent-background-color: var(--date-picker-date-cell-today-default-color-background);
23
-
24
- .rdp-selected {
25
- font-size: unset;
26
-
27
- .rdp-day_button{
28
- background-color: var(--rdp-accent-background-color);
29
- }
30
- }
31
-
32
- .rdp-footer {
33
- display: flex;
34
- justify-content: flex-end;
35
- }
30
+ .ds-date-picker-calendar-header__nav {
31
+ display: flex;
32
+ gap: var(--spacing-small);
33
+ }
34
+
35
+ .ds-date-picker-calendar-header__nav-button {
36
+ display: inline-flex;
37
+ align-items: center;
38
+ justify-content: center;
39
+ inline-size: var(--form-field-text-medium-height);
40
+ block-size: var(--form-field-text-medium-height);
41
+ border: none;
42
+ border-radius: var(--border-radius-small);
43
+ background: transparent;
44
+ color: var(--form-field-icon-default-color-icon);
45
+ cursor: pointer;
46
+
47
+ &:hover {
48
+ background: var(--color-grey-050);
49
+ }
50
+
51
+ &:focus-visible {
52
+ outline: var(--focus-border) solid var(--color-brand-500);
53
+ outline-offset: 0;
36
54
  }
37
-
55
+
56
+ &:focus:not(:focus-visible) {
57
+ outline: none;
58
+ }
59
+ }
60
+
61
+ @include shell.ds-date-picker-shell-calendar("date-picker");
@@ -0,0 +1,6 @@
1
+ /** Test-only; do not barrel-export or import from app/library code. */
2
+ import { screen } from '@testing-library/react';
3
+
4
+ export const getDropdownTrigger = (name: RegExp) => (
5
+ screen.getAllByRole('button', { name }).find(button => button.className.includes('ds-button--dropdown'))
6
+ );
@@ -0,0 +1,202 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { formatNativeDateTimeInputValue } from 'Components/datePicker/dateInputUtils';
3
+ import { useEffect, useState } from 'react';
4
+ import { fn } from 'storybook/test';
5
+ import { DateTimePicker, type DateTimePickerProps } from './DateTimePicker';
6
+
7
+ const timeOptions = ['09:00', '09:30', '10:00', '10:30', '11:00'] as const;
8
+
9
+ const meta = {
10
+ title: 'Components/DateTimePicker',
11
+ component: DateTimePicker,
12
+ decorators: [
13
+ Story => (
14
+ <div style={{ maxWidth: '280px', width: '100%' }}>
15
+ <Story />
16
+ </div>
17
+ ),
18
+ ],
19
+ parameters: {
20
+ layout: 'centered',
21
+ docs: {
22
+ description: {
23
+ component:
24
+ '`DateTimePicker` combines a date field with the existing `TimeInput`, keeping a single combined `Date` value. `displayFormat="native"` (default) uses a `datetime-local` input with a decorative empty-state hint span (browsers do not reliably show placeholders on native date/time fields). `"default"` and `"friendly"` use a plain text input with locale-style formatting and a real HTML placeholder.',
25
+ },
26
+ },
27
+ },
28
+ tags: ['autodocs'],
29
+ args: {
30
+ onChange: fn(),
31
+ },
32
+ argTypes: {
33
+ displayFormat: {
34
+ control: 'inline-radio',
35
+ options: ['native', 'default', 'friendly'],
36
+ description:
37
+ '`native`: ISO `datetime-local` (default). `default` / `friendly`: plain text field using the same date patterns as `DatePicker` plus time.',
38
+ },
39
+ granularity: {
40
+ control: 'inline-radio',
41
+ options: ['minute', 'second'],
42
+ description: 'Controls whether the native `datetime-local` input and embedded time input use minute or second precision.',
43
+ },
44
+ timeOptions: {
45
+ control: 'object',
46
+ description: 'When provided, the embedded time input switches to list-backed time selection.',
47
+ },
48
+ value: {
49
+ control: false,
50
+ description: 'Controlled combined datetime value for app-level state.',
51
+ },
52
+ defaultValue: {
53
+ control: false,
54
+ description: 'Uncontrolled initial combined datetime value.',
55
+ },
56
+ onChange: {
57
+ action: 'changed',
58
+ description: 'Called with the next combined `Date` value, or `undefined` when the date becomes invalid/cleared.',
59
+ },
60
+ placeholder: {
61
+ control: 'text',
62
+ description: 'Optional override for the field placeholder (defaults from `displayFormat` and `granularity`).',
63
+ },
64
+ },
65
+ } satisfies Meta<typeof DateTimePicker>;
66
+
67
+ export default meta;
68
+
69
+ type Story = StoryObj<typeof meta>;
70
+
71
+ const ControlledDateTimePicker = ({
72
+ showCurrentValue = false,
73
+ ...args
74
+ }: DateTimePickerProps & { showCurrentValue?: boolean }) => {
75
+ const [value, setValue] = useState<Date | undefined>(args.value);
76
+
77
+ useEffect(() => {
78
+ setValue(args.value);
79
+ }, [args.value]);
80
+
81
+ return (
82
+ <div style={{ display: 'grid', gap: 12, width: '100%', maxWidth: 320 }}>
83
+ <DateTimePicker
84
+ {...args}
85
+ value={value}
86
+ onChange={(nextValue) => {
87
+ setValue(nextValue);
88
+ args.onChange?.(nextValue);
89
+ }}
90
+ />
91
+ {showCurrentValue && (
92
+ <span>
93
+ Current value:
94
+ {' '}
95
+ {value ? formatNativeDateTimeInputValue(value, args.granularity) : 'None'}
96
+ </span>
97
+ )}
98
+ </div>
99
+ );
100
+ };
101
+
102
+ export const NativeTime: Story = {
103
+ args: {
104
+ value: new Date(2026, 3, 14, 14, 27),
105
+ granularity: 'minute',
106
+ },
107
+ render: args => <ControlledDateTimePicker {...args} />,
108
+ };
109
+
110
+ export const TimeList: Story = {
111
+ args: {
112
+ value: new Date(2026, 3, 14, 10, 0),
113
+ timeOptions: [...timeOptions],
114
+ },
115
+ render: args => <ControlledDateTimePicker {...args} />,
116
+ };
117
+
118
+ export const ControlledValuePreview: Story = {
119
+ args: {
120
+ value: new Date(2026, 3, 14, 14, 27),
121
+ granularity: 'minute',
122
+ },
123
+ render: args => <ControlledDateTimePicker {...args} showCurrentValue />,
124
+ };
125
+
126
+ export const PlaceholderNativeMinuteHint: Story = {
127
+ name: 'Placeholder · native minute hint',
128
+ args: {
129
+ granularity: 'minute',
130
+ },
131
+ parameters: {
132
+ docs: {
133
+ description: {
134
+ story: 'Uncontrolled, no initial value — empty-state hint shows `YYYY-MM-DDTHH:mm` (decorative span for native mode).',
135
+ },
136
+ },
137
+ },
138
+ };
139
+
140
+ export const PlaceholderNativeSecondHint: Story = {
141
+ name: 'Placeholder · native second hint',
142
+ args: {
143
+ granularity: 'second',
144
+ },
145
+ parameters: {
146
+ docs: {
147
+ description: {
148
+ story: 'Same as the minute story, but with `granularity="second"` so the hint includes seconds.',
149
+ },
150
+ },
151
+ },
152
+ };
153
+
154
+ export const DisplayFormatDefault: Story = {
155
+ name: 'Display format · default (text)',
156
+ args: {
157
+ displayFormat: 'default',
158
+ value: new Date(2026, 3, 14, 14, 27),
159
+ granularity: 'minute',
160
+ },
161
+ render: args => <ControlledDateTimePicker {...args} />,
162
+ parameters: {
163
+ docs: {
164
+ description: {
165
+ story: 'Combined value is shown and edited as **15/04/2026 14:27** instead of the native ISO string.',
166
+ },
167
+ },
168
+ },
169
+ };
170
+
171
+ export const DisplayFormatFriendly: Story = {
172
+ name: 'Display format · friendly (text)',
173
+ args: {
174
+ displayFormat: 'friendly',
175
+ value: new Date(2026, 3, 14, 14, 27),
176
+ granularity: 'minute',
177
+ },
178
+ render: args => <ControlledDateTimePicker {...args} />,
179
+ parameters: {
180
+ docs: {
181
+ description: {
182
+ story: 'Combined value uses friendly wording, e.g. **April 14th, 2026 14:27**.',
183
+ },
184
+ },
185
+ },
186
+ };
187
+
188
+ export const PlaceholderCustomOverride: Story = {
189
+ name: 'Placeholder · custom copy',
190
+ args: {
191
+ granularity: 'minute',
192
+ placeholder: 'Event start (local)',
193
+ },
194
+ parameters: {
195
+ docs: {
196
+ description: {
197
+ story: 'Pass `placeholder` to replace the default native empty-state hint.',
198
+ },
199
+ },
200
+ },
201
+ render: args => <ControlledDateTimePicker {...args} />,
202
+ };
@@ -0,0 +1,295 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+ import { fireEvent, render, screen, within } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
5
+ import { DateTimePicker } from './DateTimePicker';
6
+ import { getDropdownTrigger } from 'Components/datePicker/datePickerTestUtils.test-helpers';
7
+
8
+ describe('DateTimePicker', () => {
9
+ beforeEach(() => {
10
+ globalThis.ResizeObserver = class {
11
+ observe() {}
12
+ unobserve() {}
13
+ disconnect() {}
14
+ } as unknown as typeof ResizeObserver;
15
+ });
16
+
17
+ afterEach(() => {
18
+ vi.restoreAllMocks();
19
+ });
20
+
21
+ test('renders a date input and a picker toggle button', () => {
22
+ const { container } = render(<DateTimePicker />);
23
+
24
+ expect(container.querySelector('input[type="datetime-local"]')).toBeInTheDocument();
25
+ expect(screen.getByRole('button', { name: 'Open date and time picker' })).toBeInTheDocument();
26
+ });
27
+
28
+ test('uses a native datetime-local input with minute stepping by default', () => {
29
+ const { container } = render(<DateTimePicker />);
30
+ const input = container.querySelector('input[type="datetime-local"]');
31
+
32
+ expect(input).toHaveAttribute('step', '60');
33
+ });
34
+
35
+ test('shows default native empty-state hint when empty', () => {
36
+ const { container } = render(<DateTimePicker />);
37
+ const input = container.querySelector('input[type="datetime-local"]');
38
+ expect(input).toHaveAttribute('placeholder', '');
39
+ expect(screen.getByText('YYYY-MM-DDTHH:mm')).toBeInTheDocument();
40
+ });
41
+
42
+ test('shows native second-granularity empty-state hint', () => {
43
+ const { container } = render(<DateTimePicker granularity="second" />);
44
+ const input = container.querySelector('input[type="datetime-local"]');
45
+ expect(input).toHaveAttribute('placeholder', '');
46
+ expect(screen.getByText('YYYY-MM-DDTHH:mm:ss')).toBeInTheDocument();
47
+ });
48
+
49
+ test('allows empty-state hint to be overridden', () => {
50
+ const { container } = render(<DateTimePicker placeholder="Choose..." />);
51
+ const input = container.querySelector('input[type="datetime-local"]');
52
+ expect(input).toHaveAttribute('placeholder', '');
53
+ expect(screen.getByText('Choose...')).toBeInTheDocument();
54
+ });
55
+
56
+ test('native mode with custom placeholder uses read-only date field and opens calendar on pointer down', () => {
57
+ const { container } = render(<DateTimePicker placeholder="Choose..." />);
58
+ const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
59
+ expect(input.readOnly).toBe(true);
60
+ fireEvent.pointerDown(input);
61
+ expect(screen.getByRole('application')).toBeInTheDocument();
62
+ });
63
+
64
+ test('native mode with custom placeholder does not open calendar on keyboard focus alone', () => {
65
+ const { container } = render(<DateTimePicker placeholder="Choose..." />);
66
+ const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
67
+ fireEvent.focus(input);
68
+ expect(screen.queryByRole('application')).not.toBeInTheDocument();
69
+ });
70
+
71
+ describe('displayFormat branching', () => {
72
+ test('displayFormat="native" renders datetime-local', () => {
73
+ const { container } = render(<DateTimePicker displayFormat="native" />);
74
+ expect(container.querySelector('input[type="datetime-local"]')).toBeInTheDocument();
75
+ });
76
+
77
+ test('displayFormat="default" renders a text input with formatted placeholder', () => {
78
+ render(<DateTimePicker displayFormat="default" />);
79
+ const input = screen.getByRole('textbox');
80
+ expect(input).toHaveAttribute('placeholder', 'DD/MM/YYYY HH:mm');
81
+ });
82
+
83
+ test('displayFormat="friendly" renders a text input with friendly placeholder', () => {
84
+ render(<DateTimePicker displayFormat="friendly" />);
85
+ const input = screen.getByRole('textbox');
86
+ expect(input).toHaveAttribute('placeholder', 'Pick a date, HH:mm');
87
+ });
88
+
89
+ test('typing formatted text in default mode parses correctly', () => {
90
+ const onChange = vi.fn();
91
+ render(<DateTimePicker displayFormat="default" onChange={onChange} />);
92
+ const input = screen.getByRole('textbox');
93
+ fireEvent.change(input, { target: { value: '15/06/2024 14:30' } });
94
+
95
+ expect(onChange).toHaveBeenLastCalledWith(expect.any(Date));
96
+ const lastDate: Date = onChange.mock.lastCall![0];
97
+ expect(lastDate.getFullYear()).toBe(2024);
98
+ expect(lastDate.getMonth()).toBe(5);
99
+ expect(lastDate.getDate()).toBe(15);
100
+ expect(lastDate.getHours()).toBe(14);
101
+ expect(lastDate.getMinutes()).toBe(30);
102
+ });
103
+
104
+ test('typing formatted text in friendly mode parses correctly', () => {
105
+ const onChange = vi.fn();
106
+ render(<DateTimePicker displayFormat="friendly" onChange={onChange} />);
107
+ const input = screen.getByRole('textbox');
108
+ fireEvent.change(input, { target: { value: 'June 15th, 2024 14:30' } });
109
+
110
+ expect(onChange).toHaveBeenLastCalledWith(expect.any(Date));
111
+ const lastDate: Date = onChange.mock.lastCall![0];
112
+ expect(lastDate.getFullYear()).toBe(2024);
113
+ expect(lastDate.getMonth()).toBe(5);
114
+ expect(lastDate.getDate()).toBe(15);
115
+ expect(lastDate.getHours()).toBe(14);
116
+ expect(lastDate.getMinutes()).toBe(30);
117
+ });
118
+
119
+ test('controlled value displays in default format', () => {
120
+ render(<DateTimePicker displayFormat="default" value={new Date(2024, 5, 15, 14, 30)} />);
121
+ const input = screen.getByRole('textbox');
122
+ expect(input).toHaveValue('15/06/2024 14:30');
123
+ });
124
+
125
+ test('controlled value displays in friendly format', () => {
126
+ render(<DateTimePicker displayFormat="friendly" value={new Date(2024, 5, 15, 14, 30)} />);
127
+ const input = screen.getByRole('textbox');
128
+ expect(input).toHaveValue('June 15th, 2024 14:30');
129
+ });
130
+ });
131
+
132
+ test('typing a valid combined value emits a combined date and time value', () => {
133
+ const onChange = vi.fn();
134
+ const { container } = render(<DateTimePicker onChange={onChange} />);
135
+ const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
136
+
137
+ fireEvent.change(input, { target: { value: '2024-06-15T14:30' } });
138
+
139
+ expect(onChange).toHaveBeenLastCalledWith(expect.any(Date));
140
+ const lastDate: Date = onChange.mock.lastCall![0];
141
+ expect(lastDate.getFullYear()).toBe(2024);
142
+ expect(lastDate.getMonth()).toBe(5);
143
+ expect(lastDate.getDate()).toBe(15);
144
+ expect(lastDate.getHours()).toBe(14);
145
+ expect(lastDate.getMinutes()).toBe(30);
146
+ });
147
+
148
+ test('uses one-second stepping when granularity is second', () => {
149
+ const { container } = render(<DateTimePicker granularity="second" />);
150
+ const input = container.querySelector('input[type="datetime-local"]');
151
+
152
+ expect(input).toHaveAttribute('step', '1');
153
+ });
154
+
155
+ test('selecting a day from the calendar preserves the existing time', async () => {
156
+ const user = userEvent.setup();
157
+ const onChange = vi.fn();
158
+
159
+ render(<DateTimePicker value={new Date(2024, 5, 1, 14, 30)} onChange={onChange} />);
160
+ onChange.mockClear();
161
+
162
+ await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
163
+ await user.click(within(screen.getByRole('application')).getByRole('button', { name: /June 20/ }));
164
+
165
+ expect(onChange).toHaveBeenLastCalledWith(expect.any(Date));
166
+ const lastDate: Date = onChange.mock.lastCall![0];
167
+ expect(lastDate.getFullYear()).toBe(2024);
168
+ expect(lastDate.getMonth()).toBe(5);
169
+ expect(lastDate.getDate()).toBe(20);
170
+ expect(lastDate.getHours()).toBe(14);
171
+ expect(lastDate.getMinutes()).toBe(30);
172
+ });
173
+
174
+ test('opening the picker after typing a valid combined value syncs month, year, and time controls', async () => {
175
+ const user = userEvent.setup();
176
+ const { container } = render(<DateTimePicker />);
177
+ const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
178
+
179
+ fireEvent.change(input, { target: { value: '2026-04-14T14:50' } });
180
+ await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
181
+
182
+ expect(getDropdownTrigger(/April/i)).toBeInTheDocument();
183
+ expect(getDropdownTrigger(/2026/i)).toBeInTheDocument();
184
+ expect(screen.getByLabelText('Select time', { selector: 'input' })).toHaveValue('14:50');
185
+ });
186
+
187
+ test('time-list mode emits a combined date when an allowed time is selected', async () => {
188
+ const user = userEvent.setup();
189
+ const onChange = vi.fn();
190
+
191
+ render(
192
+ <DateTimePicker
193
+ value={new Date(2024, 5, 15, 13, 0)}
194
+ onChange={onChange}
195
+ timeOptions={['13:00', '13:30', '14:00']}
196
+ />,
197
+ );
198
+ onChange.mockClear();
199
+
200
+ await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
201
+ await user.click(screen.getByText('13:00'));
202
+ await user.click(screen.getByText('13:30'));
203
+
204
+ expect(onChange).toHaveBeenLastCalledWith(expect.any(Date));
205
+ const lastDate: Date = onChange.mock.lastCall![0];
206
+ expect(lastDate.getFullYear()).toBe(2024);
207
+ expect(lastDate.getMonth()).toBe(5);
208
+ expect(lastDate.getDate()).toBe(15);
209
+ expect(lastDate.getHours()).toBe(13);
210
+ expect(lastDate.getMinutes()).toBe(30);
211
+ });
212
+
213
+ describe('defaultValue prop', () => {
214
+ test('renders with the defaultValue when no value is provided', () => {
215
+ const { container } = render(<DateTimePicker defaultValue={new Date(2024, 5, 15, 14, 30)} />);
216
+ expect(container.querySelector('input[type="datetime-local"]')).toHaveValue('2024-06-15T14:30');
217
+ });
218
+
219
+ test('value prop takes precedence over defaultValue', () => {
220
+ const { container } = render(
221
+ <DateTimePicker value={new Date(2024, 11, 25, 9, 0)} defaultValue={new Date(2024, 5, 15, 14, 30)} />,
222
+ );
223
+ expect(container.querySelector('input[type="datetime-local"]')).toHaveValue('2024-12-25T09:00');
224
+ });
225
+
226
+ test('does not call onChange on mount with defaultValue', () => {
227
+ const onChange = vi.fn();
228
+ render(<DateTimePicker defaultValue={new Date(2024, 5, 15, 14, 30)} onChange={onChange} />);
229
+ expect(onChange).not.toHaveBeenCalled();
230
+ });
231
+ });
232
+
233
+ describe('onChange call-count correctness', () => {
234
+ test('does not call onChange on mount', () => {
235
+ const onChange = vi.fn();
236
+ render(<DateTimePicker onChange={onChange} />);
237
+ expect(onChange).not.toHaveBeenCalled();
238
+ });
239
+
240
+ test('does not call onChange on mount with an initial value', () => {
241
+ const onChange = vi.fn();
242
+ render(<DateTimePicker value={new Date(2024, 5, 15, 14, 30)} onChange={onChange} />);
243
+ expect(onChange).not.toHaveBeenCalled();
244
+ });
245
+
246
+ test('calls onChange exactly once per user input change', () => {
247
+ const onChange = vi.fn();
248
+ const { container } = render(<DateTimePicker onChange={onChange} />);
249
+ const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
250
+
251
+ fireEvent.change(input, { target: { value: '2024-06-15T14:30' } });
252
+ expect(onChange).toHaveBeenCalledTimes(1);
253
+ });
254
+ });
255
+
256
+ test('controlled value updates sync the date and time fields without firing onChange', () => {
257
+ const onChange = vi.fn();
258
+ const { rerender, container } = render(
259
+ <DateTimePicker value={new Date(2024, 5, 15, 14, 30)} onChange={onChange} />,
260
+ );
261
+ fireEvent.click(screen.getByRole('button', { name: 'Open date and time picker' }));
262
+
263
+ expect(container.querySelector('input[type="datetime-local"]')).toHaveValue('2024-06-15T14:30');
264
+ expect(screen.getByLabelText('Select time', { selector: 'input' })).toHaveValue('14:30');
265
+
266
+ onChange.mockClear();
267
+
268
+ rerender(<DateTimePicker value={new Date(2024, 11, 25, 9, 45)} onChange={onChange} />);
269
+
270
+ expect(container.querySelector('input[type="datetime-local"]')).toHaveValue('2024-12-25T09:45');
271
+ expect(screen.getByLabelText('Select time', { selector: 'input' })).toHaveValue('09:45');
272
+ expect(onChange).not.toHaveBeenCalled();
273
+ });
274
+
275
+ test('Today preserves the current time and closes the picker', async () => {
276
+ const user = userEvent.setup();
277
+ const onChange = vi.fn();
278
+
279
+ render(<DateTimePicker value={new Date(2024, 5, 15, 14, 30)} onChange={onChange} />);
280
+ onChange.mockClear();
281
+
282
+ await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
283
+ await user.click(screen.getByRole('button', { name: 'Today' }));
284
+
285
+ expect(screen.queryByRole('application')).not.toBeInTheDocument();
286
+ expect(onChange).toHaveBeenLastCalledWith(expect.any(Date));
287
+ const lastDate: Date = onChange.mock.lastCall![0];
288
+ const now = new Date();
289
+ expect(lastDate.getFullYear()).toBe(now.getFullYear());
290
+ expect(lastDate.getMonth()).toBe(now.getMonth());
291
+ expect(lastDate.getDate()).toBe(now.getDate());
292
+ expect(lastDate.getHours()).toBe(14);
293
+ expect(lastDate.getMinutes()).toBe(30);
294
+ });
295
+ });