@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,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>
@@ -0,0 +1,82 @@
1
+ import classNames from 'classnames';
2
+ import { SelectDropdown } from 'Components/formField/inputs/selectDropdown/SelectDropdown';
3
+ import { Icon } from 'Components/icon/Icon';
4
+ import { addMonths, format } from 'date-fns';
5
+ import { useMemo } from 'react';
6
+
7
+ type DatePickerCalendarHeaderProps = {
8
+ month: Date;
9
+ onMonthChange: (month: Date) => void;
10
+ className?: string;
11
+ };
12
+
13
+ const monthOptions = Array.from({ length: 12 }, (_, index) => ({
14
+ label: format(new Date(2000, index, 1), 'MMMM'),
15
+ value: String(index),
16
+ }));
17
+
18
+ export const DatePickerCalendarHeader = ({
19
+ month,
20
+ onMonthChange,
21
+ className,
22
+ }: DatePickerCalendarHeaderProps) => {
23
+ // Computed once per mount ([]): anchor year is the calendar header mount time, not the month being viewed
24
+ // nor a controlled value's year. Remounting when the popover opens keeps this practical; edge cases include
25
+ // a very long-lived mount across New Year's, or a controlled date far outside mountYear ± 100 until remount.
26
+ const yearOptions = useMemo(() => {
27
+ const thisYear = new Date().getFullYear();
28
+ return Array.from({ length: 201 }, (_, index) => {
29
+ const year = thisYear - 100 + index;
30
+ return { label: String(year), value: String(year) };
31
+ });
32
+ }, []);
33
+
34
+ const handleMonthSelection = (selectedValues: string[]) => {
35
+ const nextMonthIndex = Number.parseInt(selectedValues[0] ?? String(month.getMonth()), 10);
36
+ if (Number.isNaN(nextMonthIndex)) return;
37
+
38
+ onMonthChange(new Date(month.getFullYear(), nextMonthIndex, 1));
39
+ };
40
+
41
+ const handleYearSelection = (selectedValues: string[]) => {
42
+ const nextYear = Number.parseInt(selectedValues[0] ?? String(month.getFullYear()), 10);
43
+ if (Number.isNaN(nextYear)) return;
44
+
45
+ onMonthChange(new Date(nextYear, month.getMonth(), 1));
46
+ };
47
+
48
+ return (
49
+ <div className={classNames('ds-date-picker-calendar-header', className)}>
50
+ <div className="ds-date-picker-calendar-header__selectors">
51
+ <SelectDropdown
52
+ options={monthOptions}
53
+ selectedValues={[String(month.getMonth())]}
54
+ onSelectionChange={handleMonthSelection}
55
+ />
56
+ <SelectDropdown
57
+ options={yearOptions}
58
+ selectedValues={[String(month.getFullYear())]}
59
+ onSelectionChange={handleYearSelection}
60
+ />
61
+ </div>
62
+ <div className="ds-date-picker-calendar-header__nav">
63
+ <button
64
+ type="button"
65
+ className="ds-date-picker-calendar-header__nav-button"
66
+ aria-label="Go to the Previous Month"
67
+ onClick={() => onMonthChange(addMonths(month, -1))}
68
+ >
69
+ <Icon name="chevron-left" size={16} />
70
+ </button>
71
+ <button
72
+ type="button"
73
+ className="ds-date-picker-calendar-header__nav-button"
74
+ aria-label="Go to the Next Month"
75
+ onClick={() => onMonthChange(addMonths(month, 1))}
76
+ >
77
+ <Icon name="chevron-right" size={16} />
78
+ </button>
79
+ </div>
80
+ </div>
81
+ );
82
+ };
@@ -0,0 +1,152 @@
1
+ @mixin ds-date-picker-shell-field($block-suffix) {
2
+ $b: 'ds-#{$block-suffix}';
3
+
4
+ .#{$b}__field {
5
+ position: relative;
6
+ min-width: 0;
7
+ width: 100%;
8
+
9
+ .#{$b}__input {
10
+ min-width: 0;
11
+ width: 100%;
12
+ padding-right: calc(var(--form-field-text-medium-height) + var(--spacing-small));
13
+ overflow: hidden;
14
+ text-overflow: ellipsis;
15
+ white-space: nowrap;
16
+ appearance: none;
17
+
18
+ &::-webkit-calendar-picker-indicator {
19
+ display: none;
20
+ appearance: none;
21
+ }
22
+
23
+ &::-webkit-inner-spin-button {
24
+ display: none;
25
+ }
26
+ }
27
+
28
+ &.#{$b}__field--show-hint:not(:focus-within) .#{$b}__input {
29
+ color: transparent;
30
+ -webkit-text-fill-color: transparent;
31
+ caret-color: var(--form-field-text-placeholder-color-text);
32
+ }
33
+
34
+ &.#{$b}__field--show-hint:not(:focus-within) .#{$b}__input::-webkit-datetime-edit,
35
+ &.#{$b}__field--show-hint:not(:focus-within) .#{$b}__input::-webkit-datetime-edit-fields-wrapper {
36
+ color: transparent;
37
+ -webkit-text-fill-color: transparent;
38
+ }
39
+
40
+ .#{$b}__field-hint {
41
+ position: absolute;
42
+ inset-block-start: 0;
43
+ inset-inline-start: var(--form-field-spacing-horizontal);
44
+ display: flex;
45
+ align-items: center;
46
+ block-size: var(--form-field-text-medium-height);
47
+ max-inline-size: calc(
48
+ 100% - var(--form-field-spacing-horizontal) * 2 - var(--form-field-text-medium-height) - var(--spacing-small)
49
+ );
50
+ overflow: hidden;
51
+ text-overflow: ellipsis;
52
+ white-space: nowrap;
53
+ color: var(--form-field-text-placeholder-color-text);
54
+ pointer-events: none;
55
+ z-index: 1;
56
+ }
57
+
58
+ &.#{$b}__field--show-hint:focus-within .#{$b}__field-hint {
59
+ opacity: 0;
60
+ }
61
+ }
62
+ }
63
+
64
+ @mixin ds-date-picker-shell-button($block-suffix) {
65
+ $b: 'ds-#{$block-suffix}';
66
+
67
+ .#{$b}__button {
68
+ position: absolute;
69
+ right: 0;
70
+ top: 0;
71
+
72
+ svg {
73
+ stroke: var(--form-field-icon-default-color-icon);
74
+ }
75
+ }
76
+
77
+ .#{$b}__button.ds-button--text-link {
78
+ &:focus-visible {
79
+ color: var(--button-medium-text-link-focus-color-text);
80
+ outline: var(--focus-border) solid var(--button-medium-text-link-focus-color-focus);
81
+ }
82
+
83
+ &:focus:not(:focus-visible) {
84
+ outline: none;
85
+ color: var(--button-medium-text-link-default-color-text);
86
+ background-color: var(--button-medium-text-link-default-color-background);
87
+ }
88
+ }
89
+ }
90
+
91
+ @mixin ds-date-picker-shell-popup($block-suffix) {
92
+ $b: 'ds-#{$block-suffix}';
93
+
94
+ .#{$b}__popup {
95
+ display: grid;
96
+ gap: var(--spacing-medium);
97
+ width: max-content;
98
+ min-width: max(var(--radix-popover-trigger-width), 16rem);
99
+ max-width: min(100vw - var(--spacing-medium) * 2, 28rem);
100
+ box-shadow: var(--shadow-small);
101
+ padding: var(--date-picker-spacing-vertical) var(--date-picker-spacing-horizontal);
102
+ border-radius: var(--date-picker-radius);
103
+ background-color: var(--date-picker-color-background);
104
+ }
105
+ }
106
+
107
+ @mixin ds-date-picker-shell-calendar($block-suffix) {
108
+ $b: 'ds-#{$block-suffix}';
109
+
110
+ .#{$b}__calendar {
111
+ --rdp-accent-color: var(--date-picker-date-cell-today-default-color-text);
112
+ --rdp-accent-background-color: var(--date-picker-date-cell-today-default-color-background);
113
+
114
+ .rdp-month_caption {
115
+ display: none;
116
+ }
117
+
118
+ .rdp-selected {
119
+ font-size: unset;
120
+
121
+ .rdp-day_button {
122
+ background-color: var(--rdp-accent-background-color);
123
+ }
124
+ }
125
+
126
+ .rdp-day_button {
127
+ &:focus {
128
+ outline: none;
129
+ }
130
+
131
+ &:focus-visible {
132
+ outline: var(--focus-border) solid var(--color-brand-500);
133
+ outline-offset: 0;
134
+ }
135
+
136
+ &:focus:not(:focus-visible) {
137
+ outline: none;
138
+ box-shadow: none;
139
+ }
140
+ }
141
+
142
+ .rdp-selected .rdp-day_button:focus:not(:focus-visible) {
143
+ outline: none;
144
+ box-shadow: none;
145
+ }
146
+
147
+ .rdp-footer {
148
+ display: flex;
149
+ justify-content: flex-end;
150
+ }
151
+ }
152
+ }
@@ -0,0 +1,117 @@
1
+ import type { TimeGranularity, TimeValue } from 'Components/formField/inputs/time/TimeInput';
2
+ import { format, isValid, parse, startOfDay } from 'date-fns';
3
+
4
+ export type DateDisplayFormat = 'default' | 'friendly';
5
+
6
+ /** `DateTimePicker` only: `native` uses `datetime-local`; `default` / `friendly` use `type="text"` with formatted date+time strings. */
7
+ export type DateTimePickerDisplayFormat = DateDisplayFormat | 'native';
8
+
9
+ /** Date format. */
10
+ const DATE_FORMAT_PATTERNS: Record<DateDisplayFormat, string> = {
11
+ default: 'dd/MM/yyyy',
12
+ friendly: 'MMMM do, yyyy',
13
+ };
14
+
15
+ /** Placeholder copy for each DATE_FORMAT_PATTERNS. */
16
+ const DATE_PLACEHOLDERS: Record<DateDisplayFormat, string> = {
17
+ default: 'DD/MM/YYYY',
18
+ friendly: 'Pick a date',
19
+ };
20
+
21
+ export const getDateFormatString = (displayFormat: DateDisplayFormat = 'default') => DATE_FORMAT_PATTERNS[displayFormat];
22
+
23
+ export const getTimeFormatString = (granularity: TimeGranularity) => (
24
+ granularity === 'second' ? 'HH:mm:ss' : 'HH:mm'
25
+ );
26
+
27
+ export const getDateTimeFormatString = (
28
+ displayFormat: DateDisplayFormat = 'default',
29
+ granularity: TimeGranularity = 'minute',
30
+ ) => `${getDateFormatString(displayFormat)} ${getTimeFormatString(granularity)}`;
31
+
32
+ export const getDatePlaceholder = (displayFormat: DateDisplayFormat = 'default') => DATE_PLACEHOLDERS[displayFormat];
33
+
34
+ export const getDateTimePlaceholder = (
35
+ displayFormat: DateDisplayFormat = 'default',
36
+ granularity: TimeGranularity = 'minute',
37
+ ) => {
38
+ const timeHint = granularity === 'second' ? 'HH:mm:ss' : 'HH:mm';
39
+ const dateHint = getDatePlaceholder(displayFormat);
40
+ if (displayFormat === 'friendly') {
41
+ return `${dateHint}, ${timeHint}`;
42
+ }
43
+ return `${dateHint} ${timeHint}`;
44
+ };
45
+
46
+ /** Placeholder hint for native `datetime-local` values (not tied to `displayFormat`). */
47
+ export const getNativeDateTimePlaceholder = (granularity: TimeGranularity = 'minute') => (
48
+ granularity === 'second' ? 'YYYY-MM-DDTHH:mm:ss' : 'YYYY-MM-DDTHH:mm'
49
+ );
50
+
51
+ /** Placeholder hint for native `type="date"` (`DatePicker`). */
52
+ export const getNativeDatePlaceholder = () => 'YYYY-MM-DD';
53
+
54
+ const NATIVE_DATE_FORMAT = 'yyyy-MM-dd';
55
+
56
+ export const formatNativeDateInputValue = (value: Date) => format(value, NATIVE_DATE_FORMAT);
57
+
58
+ export const parseNativeDateInputValue = (value: string): Date | undefined => {
59
+ const parsedDate = parse(value, NATIVE_DATE_FORMAT, new Date());
60
+ return isValid(parsedDate) ? startOfDay(parsedDate) : undefined;
61
+ };
62
+
63
+ export const getNativeDateTimeFormatString = (granularity: TimeGranularity = 'minute') => (
64
+ granularity === 'second' ? "yyyy-MM-dd'T'HH:mm:ss" : "yyyy-MM-dd'T'HH:mm"
65
+ );
66
+
67
+ export const parseDateInputValue = (value: string, displayFormat: DateDisplayFormat = 'default'): Date | undefined => {
68
+ const parsedDate = parse(value, getDateFormatString(displayFormat), new Date());
69
+ return isValid(parsedDate) ? startOfDay(parsedDate) : undefined;
70
+ };
71
+
72
+ export const parseDateTimeInputValue = (
73
+ value: string,
74
+ displayFormat: DateDisplayFormat = 'default',
75
+ granularity: TimeGranularity = 'minute',
76
+ ): Date | undefined => {
77
+ const parsedDate = parse(value, getDateTimeFormatString(displayFormat, granularity), new Date());
78
+ return isValid(parsedDate) ? parsedDate : undefined;
79
+ };
80
+
81
+ export const parseNativeDateTimeInputValue = (
82
+ value: string,
83
+ granularity: TimeGranularity = 'minute',
84
+ ): Date | undefined => {
85
+ const parsedDate = parse(value, getNativeDateTimeFormatString(granularity), new Date());
86
+ return isValid(parsedDate) ? parsedDate : undefined;
87
+ };
88
+
89
+ export const formatDateInputValue = (value: Date, displayFormat: DateDisplayFormat = 'default') => (
90
+ format(value, getDateFormatString(displayFormat))
91
+ );
92
+
93
+ export const formatDateTimeInputValue = (
94
+ value: Date,
95
+ displayFormat: DateDisplayFormat = 'default',
96
+ granularity: TimeGranularity = 'minute',
97
+ ) => format(value, getDateTimeFormatString(displayFormat, granularity));
98
+
99
+ export const formatNativeDateTimeInputValue = (
100
+ value: Date,
101
+ granularity: TimeGranularity = 'minute',
102
+ ) => format(value, getNativeDateTimeFormatString(granularity));
103
+
104
+ export const formatTimeInputValue = (value: Date, granularity: TimeGranularity): TimeValue => (
105
+ format(value, getTimeFormatString(granularity)) as TimeValue
106
+ );
107
+
108
+ export const mergeDateAndTime = (date: Date, timeValue: string, granularity: TimeGranularity): Date => {
109
+ const [rawHours = '0', rawMinutes = '0', rawSeconds = '0'] = timeValue.split(':');
110
+ const hours = Number.parseInt(rawHours, 10) || 0;
111
+ const minutes = Number.parseInt(rawMinutes, 10) || 0;
112
+ const seconds = granularity === 'second' ? (Number.parseInt(rawSeconds, 10) || 0) : 0;
113
+ const merged = new Date(date);
114
+
115
+ merged.setHours(hours, minutes, seconds, 0);
116
+ return merged;
117
+ };