@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
@@ -0,0 +1,293 @@
1
+ import classNames from 'classnames';
2
+ import { Button } from 'Components/button/Button';
3
+ import type { ComboboxSearchType } from 'Components/combobox/types';
4
+ import {
5
+ formatDateTimeInputValue,
6
+ formatNativeDateTimeInputValue,
7
+ formatTimeInputValue,
8
+ getDateTimePlaceholder,
9
+ getNativeDateTimePlaceholder,
10
+ mergeDateAndTime,
11
+ parseDateTimeInputValue,
12
+ parseNativeDateTimeInputValue,
13
+ type DateDisplayFormat,
14
+ type DateTimePickerDisplayFormat,
15
+ } from 'Components/datePicker/dateInputUtils';
16
+ import { DatePickerCalendarHeader } from 'Components/datePicker/DatePickerCalendarHeader';
17
+ import { TextInput } from 'Components/formField/inputs/text/TextInput';
18
+ import { TimeInput, type TimeGranularity, type TimeValue } from 'Components/formField/inputs/time/TimeInput';
19
+ import { Popover } from 'radix-ui';
20
+ import { useCallback, useContext, useEffect, useRef, useState, type ChangeEvent, type PointerEvent } from 'react';
21
+ import { DayPicker } from 'react-day-picker';
22
+ import { PopupParentContext } from 'Utils/PopupParentContext';
23
+
24
+ export type DateTimePickerProps = {
25
+ 'className'?: string;
26
+ 'onChange'?: (newDate?: Date) => void;
27
+ 'id'?: string;
28
+ 'hasError'?: boolean;
29
+ 'aria-describedby'?: string;
30
+ 'aria-invalid'?: boolean;
31
+ 'value'?: Date;
32
+ 'defaultValue'?: Date;
33
+ 'granularity'?: TimeGranularity;
34
+ 'displayFormat'?: DateTimePickerDisplayFormat;
35
+ 'timeOptions'?: TimeValue[];
36
+ 'searchType'?: ComboboxSearchType;
37
+ 'highlightStringMatches'?: boolean;
38
+ 'placeholder'?: string;
39
+ };
40
+
41
+ export const DateTimePicker = (props: DateTimePickerProps) => {
42
+ const {
43
+ className,
44
+ onChange,
45
+ id,
46
+ hasError,
47
+ 'aria-describedby': ariaDescribedBy,
48
+ 'aria-invalid': ariaInvalid,
49
+ value,
50
+ defaultValue,
51
+ displayFormat = 'native',
52
+ granularity = 'minute',
53
+ timeOptions,
54
+ searchType = 'prefix',
55
+ highlightStringMatches = false,
56
+ placeholder,
57
+ } = props;
58
+
59
+ const isNative = displayFormat === 'native';
60
+
61
+ const formatInputValue = useCallback(
62
+ (date: Date) =>
63
+ displayFormat === 'native'
64
+ ? formatNativeDateTimeInputValue(date, granularity)
65
+ : formatDateTimeInputValue(date, displayFormat as DateDisplayFormat, granularity),
66
+ [displayFormat, granularity],
67
+ );
68
+
69
+ const parseInputValue = useCallback(
70
+ (raw: string) =>
71
+ displayFormat === 'native'
72
+ ? parseNativeDateTimeInputValue(raw, granularity)
73
+ : parseDateTimeInputValue(raw, displayFormat as DateDisplayFormat, granularity),
74
+ [displayFormat, granularity],
75
+ );
76
+
77
+ const resolvedPlaceholder = placeholder ?? (
78
+ isNative
79
+ ? getNativeDateTimePlaceholder(granularity)
80
+ : getDateTimePlaceholder(displayFormat as DateDisplayFormat, granularity)
81
+ );
82
+
83
+ const initialValue = value ?? defaultValue;
84
+ const isControlled = value !== undefined;
85
+ const [month, setMonth] = useState(initialValue ?? new Date());
86
+ const [selectedDateTime, setSelectedDateTime] = useState<Date | undefined>(initialValue);
87
+ const [inputValue, setInputValue] = useState(
88
+ initialValue ? formatInputValue(initialValue) : '',
89
+ );
90
+ const [timeInputValue, setTimeInputValue] = useState(initialValue ? formatTimeInputValue(initialValue, granularity) : '');
91
+ const [isPickerOpen, setIsPickerOpen] = useState(false);
92
+
93
+ const inputRef = useRef<HTMLInputElement>(null);
94
+ const selectedDateTimeRef = useRef(selectedDateTime);
95
+ selectedDateTimeRef.current = selectedDateTime;
96
+
97
+ const showFieldHint = isNative && selectedDateTime === undefined && inputValue === '';
98
+ const hasCustomEmptyHint = showFieldHint && placeholder !== undefined;
99
+
100
+ /** Custom empty hint in native mode: open only on pointer (not Tab focus). */
101
+ const onNativeFieldHintPointerDown = (e: PointerEvent<HTMLInputElement>) => {
102
+ if (hasCustomEmptyHint) {
103
+ e.preventDefault();
104
+ setIsPickerOpen(true);
105
+ }
106
+ };
107
+
108
+ useEffect(() => {
109
+ if (!isControlled) {
110
+ return;
111
+ }
112
+ const valueTime = value?.getTime();
113
+ const selectedTime = selectedDateTimeRef.current?.getTime();
114
+ if (valueTime !== selectedTime) {
115
+ setSelectedDateTime(value);
116
+ setMonth(value ?? new Date());
117
+ setInputValue(value ? formatInputValue(value) : '');
118
+ setTimeInputValue(value ? formatTimeInputValue(value, granularity) : '');
119
+ }
120
+ }, [value, isControlled, granularity, formatInputValue]);
121
+
122
+ const mountedRef = useRef(false);
123
+ useEffect(() => {
124
+ // Skip reformat on initial mount (`useState` already seeded input/time strings). Only re-run when
125
+ // `granularity` or `displayFormat` changes after mount.
126
+ if (!mountedRef.current) {
127
+ mountedRef.current = true;
128
+ return;
129
+ }
130
+ const current = selectedDateTimeRef.current;
131
+ if (current) {
132
+ setInputValue(formatInputValue(current));
133
+ setTimeInputValue(formatTimeInputValue(current, granularity));
134
+ }
135
+ else {
136
+ setInputValue('');
137
+ setTimeInputValue('');
138
+ }
139
+ }, [granularity, displayFormat, formatInputValue]);
140
+
141
+ const onInputChange = (event: ChangeEvent<HTMLInputElement>) => {
142
+ const nextInputValue = event.target.value;
143
+ setInputValue(nextInputValue);
144
+
145
+ const parsedDate = parseInputValue(nextInputValue);
146
+
147
+ if (parsedDate) {
148
+ setSelectedDateTime(parsedDate);
149
+ setMonth(parsedDate);
150
+ setTimeInputValue(formatTimeInputValue(parsedDate, granularity));
151
+ onChange?.(parsedDate);
152
+ }
153
+ else {
154
+ setSelectedDateTime(undefined);
155
+ onChange?.(undefined);
156
+ }
157
+ };
158
+
159
+ const handleTimeChange = (nextTimeValue: string) => {
160
+ setTimeInputValue(nextTimeValue);
161
+
162
+ if (!selectedDateTime) {
163
+ return;
164
+ }
165
+
166
+ const nextDateTime = mergeDateAndTime(selectedDateTime, nextTimeValue, granularity);
167
+ setSelectedDateTime(nextDateTime);
168
+ setInputValue(formatInputValue(nextDateTime));
169
+ onChange?.(nextDateTime);
170
+ };
171
+
172
+ const handleDayPickerSelect = (nextDate?: Date) => {
173
+ if (!nextDate) {
174
+ setSelectedDateTime(undefined);
175
+ setInputValue('');
176
+ setIsPickerOpen(false);
177
+ onChange?.(undefined);
178
+ return;
179
+ }
180
+
181
+ const nextDateTime = mergeDateAndTime(nextDate, timeInputValue, granularity);
182
+ setSelectedDateTime(nextDateTime);
183
+ setMonth(nextDateTime);
184
+ setInputValue(formatInputValue(nextDateTime));
185
+ // UX trade-off: closing here means the user must reopen the popover to set time if they picked the day first.
186
+ // Alternatives (keep open until time is chosen) are a deliberate product change.
187
+ setIsPickerOpen(false);
188
+ onChange?.(nextDateTime);
189
+ };
190
+
191
+ const handleTodayClick = () => {
192
+ const nextDate = mergeDateAndTime(new Date(), timeInputValue, granularity);
193
+ setSelectedDateTime(nextDate);
194
+ setMonth(nextDate);
195
+ setInputValue(formatInputValue(nextDate));
196
+ setIsPickerOpen(false);
197
+ onChange?.(nextDate);
198
+ };
199
+
200
+ const popupParentRef = useContext(PopupParentContext);
201
+
202
+ return (
203
+ <div className={classNames('ds-date-time-picker', className)}>
204
+ <div
205
+ className={classNames(
206
+ 'ds-date-time-picker__field',
207
+ { 'ds-date-time-picker__field--show-hint': showFieldHint },
208
+ )}
209
+ >
210
+ <TextInput
211
+ ref={inputRef}
212
+ type={isNative ? 'datetime-local' : 'text'}
213
+ value={inputValue}
214
+ onChange={onInputChange}
215
+ onPointerDown={onNativeFieldHintPointerDown}
216
+ readOnly={hasCustomEmptyHint}
217
+ id={id}
218
+ step={isNative ? (granularity === 'second' ? 1 : 60) : undefined}
219
+ placeholder={showFieldHint ? '' : resolvedPlaceholder}
220
+ hasError={hasError}
221
+ aria-describedby={ariaDescribedBy}
222
+ aria-invalid={ariaInvalid}
223
+ className="ds-date-time-picker__input"
224
+ />
225
+ {showFieldHint
226
+ ? (
227
+ <span className="ds-date-time-picker__field-hint" aria-hidden="true">
228
+ {resolvedPlaceholder}
229
+ </span>
230
+ )
231
+ : null}
232
+ </div>
233
+ <Popover.Root open={isPickerOpen} onOpenChange={open => setIsPickerOpen(open)}>
234
+ <Popover.Trigger asChild>
235
+ <Button
236
+ className="ds-date-time-picker__button"
237
+ variant="text-link"
238
+ iconLeftName="date"
239
+ iconLeftScreenReaderText="Open date and time picker"
240
+ />
241
+ </Popover.Trigger>
242
+ <Popover.Portal container={popupParentRef.current}>
243
+ <Popover.Content
244
+ align="end"
245
+ sideOffset={5}
246
+ onOpenAutoFocus={e => e.preventDefault()}
247
+ onCloseAutoFocus={(e) => {
248
+ e.preventDefault();
249
+ inputRef.current?.focus();
250
+ }}
251
+ >
252
+ <div className="ds-date-time-picker__popup">
253
+ <TimeInput
254
+ className="ds-date-time-picker__time"
255
+ aria-label="Select time"
256
+ value={timeInputValue as TimeValue | ''}
257
+ onValueChange={handleTimeChange}
258
+ granularity={granularity}
259
+ options={timeOptions}
260
+ searchType={searchType}
261
+ highlightStringMatches={highlightStringMatches}
262
+ hasError={hasError}
263
+ />
264
+ <DatePickerCalendarHeader month={month} onMonthChange={setMonth} />
265
+ <DayPicker
266
+ className="ds-date-time-picker__calendar"
267
+ month={month}
268
+ onMonthChange={setMonth}
269
+ autoFocus
270
+ role="application"
271
+ mode="single"
272
+ selected={selectedDateTime}
273
+ onSelect={handleDayPickerSelect}
274
+ hideNavigation
275
+ footer={(
276
+ <Button
277
+ variant="text-link"
278
+ onClick={handleTodayClick}
279
+ className="ds-date-time-picker__today-button"
280
+ >
281
+ Today
282
+ </Button>
283
+ )}
284
+ />
285
+ </div>
286
+ </Popover.Content>
287
+ </Popover.Portal>
288
+ </Popover.Root>
289
+ </div>
290
+ );
291
+ };
292
+
293
+ export type { DateTimePickerDisplayFormat } from 'Components/datePicker/dateInputUtils';
@@ -0,0 +1,17 @@
1
+ @use "../datePicker/date-field-hint" as shell;
2
+
3
+ .ds-date-time-picker {
4
+ position: relative;
5
+ min-width: 0;
6
+
7
+ @include shell.ds-date-picker-shell-field("date-time-picker");
8
+ @include shell.ds-date-picker-shell-button("date-time-picker");
9
+ }
10
+
11
+ @include shell.ds-date-picker-shell-popup("date-time-picker");
12
+
13
+ .ds-date-time-picker__time {
14
+ width: 100%;
15
+ }
16
+
17
+ @include shell.ds-date-picker-shell-calendar("date-time-picker");
@@ -42,7 +42,7 @@ export const Default = {
42
42
  },
43
43
  'inputType': {
44
44
  control: 'select',
45
- options: ['text', 'textarea', 'number', 'colourPicker', 'selectDropdown', 'datePicker', 'combobox'],
45
+ options: ['text', 'textarea', 'number', 'time', 'colourPicker', 'selectDropdown', 'datePicker', 'combobox'],
46
46
  description: 'Input type',
47
47
  },
48
48
  'inputProps.size': {
@@ -114,6 +114,15 @@ export const FormExample: Story = {
114
114
  placeholder: 'Enter your age',
115
115
  }}
116
116
  />
117
+ <FormField
118
+ id="start-time"
119
+ label="Start time"
120
+ inputType="time"
121
+ inputProps={{
122
+ 'aria-label': 'Start time',
123
+ 'defaultValue': '14:30',
124
+ }}
125
+ />
117
126
  <FormField
118
127
  id="colour-dropdown"
119
128
  label="Colour"
@@ -1,7 +1,7 @@
1
- import { expect, test, describe } from 'vitest';
2
- import { FormField } from './FormField';
3
- import { render, screen } from '@testing-library/react';
4
1
  import '@testing-library/jest-dom/vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { describe, expect, test } from 'vitest';
4
+ import { FormField } from './FormField';
5
5
 
6
6
  describe('FormField component', () => {
7
7
  test('renders a form field', () => {
@@ -45,6 +45,12 @@ describe('FormField component', () => {
45
45
  expect(input).toHaveClass('ds-number-input');
46
46
  });
47
47
 
48
+ test('renders a time input when inputType is time', () => {
49
+ render(<FormField id="niceid" inputType="time" inputProps={{ 'aria-label': 'Start time' }} />);
50
+ const input = screen.getByLabelText('Start time');
51
+ expect(input).toHaveAttribute('type', 'time');
52
+ });
53
+
48
54
  test('renders a colour picker dropdown when inputType is colourPicker', () => {
49
55
  render(<FormField id="niceid" inputType="colourPicker" />);
50
56
  const input = screen.getByRole('button');
@@ -58,8 +64,8 @@ describe('FormField component', () => {
58
64
  });
59
65
 
60
66
  test('renders a date picker when inputType is datePicker', () => {
61
- render(<FormField id="niceid" inputType="datePicker" />);
62
- expect(screen.getByRole('textbox')).toBeInTheDocument();
67
+ const { container } = render(<FormField id="niceid" inputType="datePicker" />);
68
+ expect(container.querySelector('input[type="date"]')).toBeInTheDocument();
63
69
  expect(screen.getByRole('button', { name: 'Open date picker' })).toBeInTheDocument();
64
70
  });
65
71
 
@@ -7,6 +7,7 @@ import { ColourPickerDropdown, type ColourPickerDropdownProps } from './inputs/c
7
7
  import { NumberInput, type NumberInputProps } from './inputs/number/NumberInput';
8
8
  import { SelectDropdown, type SelectDropdownInputProps } from './inputs/selectDropdown/SelectDropdown';
9
9
  import { TextInput, type TextInputProps } from './inputs/text/TextInput';
10
+ import { TimeInput, type TimeInputProps } from './inputs/time/TimeInput';
10
11
  import { TextArea, type TextAreaProps } from './inputs/textArea/TextArea';
11
12
  import { Label } from './label/Label';
12
13
 
@@ -22,6 +23,7 @@ type FormFieldProps = {
22
23
  | { inputType?: 'text'; inputProps?: TextInputProps }
23
24
  | { inputType?: 'textarea'; inputProps?: TextAreaProps }
24
25
  | { inputType?: 'number'; inputProps?: NumberInputProps }
26
+ | { inputType?: 'time'; inputProps?: TimeInputProps }
25
27
  | { inputType?: 'colourPicker'; inputProps?: ColourPickerDropdownProps }
26
28
  | { inputType?: 'selectDropdown'; inputProps?: SelectDropdownInputProps }
27
29
  | { inputType?: 'datePicker'; inputProps?: DatePickerProps }
@@ -65,6 +67,9 @@ export const FormField = (props: FormFieldProps) => {
65
67
  {inputType === 'number' && (
66
68
  <NumberInput {...sharedProps} {...(inputProps as NumberInputProps)} />
67
69
  )}
70
+ {inputType === 'time' && (
71
+ <TimeInput {...sharedProps} {...(inputProps as TimeInputProps)} />
72
+ )}
68
73
  {inputType === 'colourPicker' && (
69
74
  <ColourPickerDropdown {...sharedProps} {...(inputProps as ColourPickerDropdownProps)} />
70
75
  )}
@@ -182,4 +182,32 @@ describe('SelectDropdown component', () => {
182
182
  expect(await screen.findByText('Option 1')).toBeInTheDocument();
183
183
  expect(screen.getByText('header1')).toBeInTheDocument();
184
184
  });
185
+
186
+ test('updates the rendered selection when controlled selectedValues change', () => {
187
+ const { rerender } = render(
188
+ <SelectDropdown
189
+ options={[
190
+ { label: 'June', value: '5' },
191
+ { label: 'July', value: '6' },
192
+ ]}
193
+ selectedValues={['5']}
194
+ onSelectionChange={vi.fn()}
195
+ />,
196
+ );
197
+
198
+ expect(screen.getByRole('button', { name: /june/i })).toBeInTheDocument();
199
+
200
+ rerender(
201
+ <SelectDropdown
202
+ options={[
203
+ { label: 'June', value: '5' },
204
+ { label: 'July', value: '6' },
205
+ ]}
206
+ selectedValues={['6']}
207
+ onSelectionChange={vi.fn()}
208
+ />,
209
+ );
210
+
211
+ expect(screen.getByRole('button', { name: /july/i })).toBeInTheDocument();
212
+ });
185
213
  });
@@ -16,6 +16,7 @@ export type SelectDropdownInputProps = {
16
16
  'id'?: string;
17
17
  'alwaysShowPlaceholder'?: boolean;
18
18
  'initialSelectedValues'?: string[];
19
+ 'selectedValues'?: string[];
19
20
  'open'?: boolean;
20
21
  'onOpenChange'?: (open: boolean) => void;
21
22
  };
@@ -33,11 +34,14 @@ export const SelectDropdown = (props: SelectDropdownInputProps) => {
33
34
  'aria-invalid': ariaInvalid,
34
35
  alwaysShowPlaceholder = false,
35
36
  initialSelectedValues = [],
37
+ selectedValues: controlledSelectedValues,
36
38
  open,
37
39
  onOpenChange,
38
40
  } = props;
39
41
 
40
- const [selectedValues, setSelectedValues] = useState<string[]>(initialSelectedValues);
42
+ const isControlled = controlledSelectedValues !== undefined;
43
+ const [internalSelectedValues, setInternalSelectedValues] = useState<string[]>(initialSelectedValues);
44
+ const selectedValues = isControlled ? controlledSelectedValues : internalSelectedValues;
41
45
  const [renderedSelectContent, setRenderedSelectContent] = useState('');
42
46
  const selectedValuesRef = useRef(selectedValues);
43
47
  selectedValuesRef.current = selectedValues;
@@ -90,7 +94,9 @@ export const SelectDropdown = (props: SelectDropdownInputProps) => {
90
94
  else {
91
95
  nextValues = [...prev, value];
92
96
  }
93
- setSelectedValues(nextValues);
97
+ if (!isControlled) {
98
+ setInternalSelectedValues(nextValues);
99
+ }
94
100
  onSelectionChange?.(nextValues);
95
101
  };
96
102
 
@@ -1,12 +1,12 @@
1
1
  import classNames from 'classnames';
2
- import { type InputHTMLAttributes } from 'react';
2
+ import { forwardRef, type InputHTMLAttributes } from 'react';
3
3
 
4
4
  export type TextInputProps = {
5
5
  size?: 'M' | 'S';
6
6
  hasError?: boolean;
7
7
  } & Omit<InputHTMLAttributes<HTMLInputElement>, 'size'>;
8
8
 
9
- export const TextInput = (props: TextInputProps) => {
9
+ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>((props, ref) => {
10
10
  const {
11
11
  size = 'M',
12
12
  hasError,
@@ -27,9 +27,12 @@ export const TextInput = (props: TextInputProps) => {
27
27
 
28
28
  return (
29
29
  <input
30
+ ref={ref}
30
31
  className={inputClasses}
31
32
  disabled={disabled}
32
33
  {...rest}
33
34
  />
34
35
  );
35
- };
36
+ });
37
+
38
+ TextInput.displayName = 'TextInput';
@@ -0,0 +1,170 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { Button } from 'Components/button/Button';
3
+ import { useEffect, useState } from 'react';
4
+ import { fn } from 'storybook/test';
5
+ import { TimeInput, type TimeInputProps, type TimeValue } from './TimeInput';
6
+
7
+ const timeOptions = ['09:00', '09:30', '10:00', '10:30', '11:00'] as const;
8
+
9
+ const meta = {
10
+ title: 'Components/FormField/Inputs/TimeInput',
11
+ component: TimeInput,
12
+ parameters: {
13
+ layout: 'centered',
14
+ docs: {
15
+ description: {
16
+ component:
17
+ '`TimeInput` supports both native time entry and a combobox-backed time list. When you pass `value`, treat it as a controlled component and update it in `onValueChange`; use `defaultValue` for uncontrolled usage.',
18
+ },
19
+ },
20
+ },
21
+ tags: ['autodocs'],
22
+ args: {
23
+ onValueChange: fn(),
24
+ },
25
+ argTypes: {
26
+ granularity: {
27
+ control: 'inline-radio',
28
+ options: ['minute', 'second'],
29
+ description: 'Controls whether the native time input works in minute or second increments.',
30
+ },
31
+ options: {
32
+ control: 'object',
33
+ description: 'When provided, `TimeInput` switches from native `input[type="time"]` to the combobox-backed time list mode.',
34
+ },
35
+ value: {
36
+ control: 'text',
37
+ description: 'Controlled value. If you provide this, update it from `onValueChange` in your app or story state.',
38
+ },
39
+ defaultValue: {
40
+ control: 'text',
41
+ description: 'Uncontrolled initial value. Use this when you want the component to manage its own state.',
42
+ },
43
+ onValueChange: {
44
+ action: 'value changed',
45
+ description: 'Called with the next string value (`HH:MM` or `HH:MM:SS`).',
46
+ },
47
+ },
48
+ } satisfies Meta<typeof TimeInput>;
49
+
50
+ export default meta;
51
+
52
+ type Story = StoryObj<typeof meta>;
53
+
54
+ const withDescription = (story: Story, description: string): Story => ({
55
+ ...story,
56
+ parameters: {
57
+ ...story.parameters,
58
+ docs: {
59
+ ...story.parameters?.docs,
60
+ description: {
61
+ story: description,
62
+ },
63
+ },
64
+ },
65
+ });
66
+
67
+ const ControlledTimeInput = (args: TimeInputProps) => {
68
+ const [value, setValue] = useState<TimeValue | ''>(args.value ?? args.defaultValue ?? '');
69
+
70
+ useEffect(() => {
71
+ setValue(args.value ?? args.defaultValue ?? '');
72
+ }, [args.defaultValue, args.value]);
73
+
74
+ return (
75
+ <TimeInput
76
+ {...args}
77
+ value={value}
78
+ onValueChange={(nextValue) => {
79
+ setValue(nextValue as TimeValue | '');
80
+ args.onValueChange?.(nextValue);
81
+ }}
82
+ />
83
+ );
84
+ };
85
+
86
+ const NativeValidationDemo = (args: TimeInputProps) => {
87
+ const [value, setValue] = useState<TimeValue | ''>(args.value ?? '');
88
+
89
+ useEffect(() => {
90
+ setValue(args.value ?? '');
91
+ }, [args.value]);
92
+
93
+ return (
94
+ <form
95
+ style={{ display: 'grid', gap: 12, width: 280 }}
96
+ onSubmit={(event) => {
97
+ event.preventDefault();
98
+ const form = event.currentTarget;
99
+ if (!form.reportValidity()) {
100
+ return;
101
+ }
102
+ }}
103
+ >
104
+ <TimeInput
105
+ {...args}
106
+ value={value}
107
+ onValueChange={(nextValue) => {
108
+ setValue(nextValue as TimeValue | '');
109
+ args.onValueChange?.(nextValue);
110
+ }}
111
+ />
112
+ <Button variant="primary" size="M" type="submit">
113
+ Submit
114
+ </Button>
115
+ </form>
116
+ );
117
+ };
118
+
119
+ export const NativeMinute: Story = withDescription({
120
+ args: {
121
+ value: '14:30',
122
+ granularity: 'minute',
123
+ },
124
+ render: args => <ControlledTimeInput {...args} />,
125
+ }, 'Controlled native time input in minute mode. This story keeps local state on purpose to demonstrate the correct `value` + `onValueChange` integration pattern.');
126
+
127
+ export const NativeSecond: Story = withDescription({
128
+ args: {
129
+ value: '14:30:15',
130
+ granularity: 'second',
131
+ },
132
+ render: args => <ControlledTimeInput {...args} />,
133
+ }, 'Controlled native time input in second mode, showing the same parent-managed state pattern with `HH:MM:SS` values.');
134
+
135
+ export const NativeWithBounds: Story = withDescription({
136
+ args: {
137
+ value: '08:30',
138
+ granularity: 'minute',
139
+ min: '09:00',
140
+ max: '17:30',
141
+ },
142
+ render: args => <NativeValidationDemo {...args} />,
143
+ }, 'Demonstrates the browser-native min/max validation flow. Enter a time outside the allowed range and submit the form to let the browser show its own native validation message.');
144
+
145
+ export const TimeList: Story = withDescription({
146
+ args: {
147
+ options: [...timeOptions],
148
+ value: '10:00',
149
+ placeholder: 'Select time',
150
+ },
151
+ render: args => <ControlledTimeInput {...args} />,
152
+ }, 'Controlled time-list mode using the combobox-backed variant. Selecting a time updates the local story state the same way an app component would.');
153
+
154
+ export const TimeListHighlightedMatches: Story = withDescription({
155
+ args: {
156
+ options: [...timeOptions],
157
+ value: '',
158
+ placeholder: 'Search times',
159
+ searchType: 'substring',
160
+ highlightStringMatches: true,
161
+ },
162
+ render: args => <ControlledTimeInput {...args} />,
163
+ }, 'Combobox-backed time-list mode with substring matching and highlighted text so consumers can review how search behaves when users type partial time fragments such as `30`.');
164
+
165
+ export const UncontrolledNative: Story = withDescription({
166
+ args: {
167
+ defaultValue: '09:30',
168
+ granularity: 'minute',
169
+ },
170
+ }, 'Uncontrolled native usage using `defaultValue`. This is useful when the parent does not need to drive the current value after initial render.');