@arbor-education/design-system.components 0.9.0 → 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 (116) hide show
  1. package/.github/workflows/release.yml +1 -1
  2. package/CHANGELOG.md +10 -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 +2 -1
  8. package/dist/components/combobox/Combobox.js.map +1 -1
  9. package/dist/components/combobox/Combobox.test.js +98 -61
  10. package/dist/components/combobox/Combobox.test.js.map +1 -1
  11. package/dist/components/combobox/useComboboxPopoverBehavior.d.ts +3 -1
  12. package/dist/components/combobox/useComboboxPopoverBehavior.d.ts.map +1 -1
  13. package/dist/components/combobox/useComboboxPopoverBehavior.js +7 -6
  14. package/dist/components/combobox/useComboboxPopoverBehavior.js.map +1 -1
  15. package/dist/components/combobox/useComboboxState.d.ts.map +1 -1
  16. package/dist/components/combobox/useComboboxState.js +4 -1
  17. package/dist/components/combobox/useComboboxState.js.map +1 -1
  18. package/dist/components/datePicker/DatePicker.d.ts +4 -1
  19. package/dist/components/datePicker/DatePicker.d.ts.map +1 -1
  20. package/dist/components/datePicker/DatePicker.js +77 -37
  21. package/dist/components/datePicker/DatePicker.js.map +1 -1
  22. package/dist/components/datePicker/DatePicker.stories.d.ts +28 -3
  23. package/dist/components/datePicker/DatePicker.stories.d.ts.map +1 -1
  24. package/dist/components/datePicker/DatePicker.stories.js +62 -9
  25. package/dist/components/datePicker/DatePicker.stories.js.map +1 -1
  26. package/dist/components/datePicker/DatePicker.test.js +133 -66
  27. package/dist/components/datePicker/DatePicker.test.js.map +1 -1
  28. package/dist/components/datePicker/DatePickerCalendarHeader.d.ts +8 -0
  29. package/dist/components/datePicker/DatePickerCalendarHeader.d.ts.map +1 -0
  30. package/dist/components/datePicker/DatePickerCalendarHeader.js +36 -0
  31. package/dist/components/datePicker/DatePickerCalendarHeader.js.map +1 -0
  32. package/dist/components/datePicker/dateInputUtils.d.ts +25 -0
  33. package/dist/components/datePicker/dateInputUtils.d.ts.map +1 -0
  34. package/dist/components/datePicker/dateInputUtils.js +60 -0
  35. package/dist/components/datePicker/dateInputUtils.js.map +1 -0
  36. package/dist/components/datePicker/datePickerTestUtils.test-helpers.d.ts +2 -0
  37. package/dist/components/datePicker/datePickerTestUtils.test-helpers.d.ts.map +1 -0
  38. package/dist/components/datePicker/datePickerTestUtils.test-helpers.js +4 -0
  39. package/dist/components/datePicker/datePickerTestUtils.test-helpers.js.map +1 -0
  40. package/dist/components/dateTimePicker/DateTimePicker.d.ts +22 -0
  41. package/dist/components/dateTimePicker/DateTimePicker.d.ts.map +1 -0
  42. package/dist/components/dateTimePicker/DateTimePicker.js +132 -0
  43. package/dist/components/dateTimePicker/DateTimePicker.js.map +1 -0
  44. package/dist/components/dateTimePicker/DateTimePicker.stories.d.ts +77 -0
  45. package/dist/components/dateTimePicker/DateTimePicker.stories.d.ts.map +1 -0
  46. package/dist/components/dateTimePicker/DateTimePicker.stories.js +163 -0
  47. package/dist/components/dateTimePicker/DateTimePicker.stories.js.map +1 -0
  48. package/dist/components/dateTimePicker/DateTimePicker.test.d.ts +2 -0
  49. package/dist/components/dateTimePicker/DateTimePicker.test.d.ts.map +1 -0
  50. package/dist/components/dateTimePicker/DateTimePicker.test.js +235 -0
  51. package/dist/components/dateTimePicker/DateTimePicker.test.js.map +1 -0
  52. package/dist/components/formField/FormField.test.d.ts.map +1 -1
  53. package/dist/components/formField/FormField.test.js +5 -5
  54. package/dist/components/formField/FormField.test.js.map +1 -1
  55. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.d.ts +1 -0
  56. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.d.ts.map +1 -1
  57. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.js +7 -3
  58. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.js.map +1 -1
  59. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.test.js +12 -0
  60. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.test.js.map +1 -1
  61. package/dist/components/formField/inputs/text/TextInput.d.ts +4 -1
  62. package/dist/components/formField/inputs/text/TextInput.d.ts.map +1 -1
  63. package/dist/components/formField/inputs/text/TextInput.js +5 -4
  64. package/dist/components/formField/inputs/text/TextInput.js.map +1 -1
  65. package/dist/components/formField/inputs/text/TextInput.stories.d.ts +4 -1
  66. package/dist/components/formField/inputs/text/TextInput.stories.d.ts.map +1 -1
  67. package/dist/components/table/Table.d.ts.map +1 -1
  68. package/dist/components/table/Table.js +2 -0
  69. package/dist/components/table/Table.js.map +1 -1
  70. package/dist/components/table/Table.stories.d.ts +1 -0
  71. package/dist/components/table/Table.stories.d.ts.map +1 -1
  72. package/dist/components/table/Table.stories.js +37 -0
  73. package/dist/components/table/Table.stories.js.map +1 -1
  74. package/dist/components/table/cellRenderers/BooleanCellRenderer.d.ts +3 -0
  75. package/dist/components/table/cellRenderers/BooleanCellRenderer.d.ts.map +1 -0
  76. package/dist/components/table/cellRenderers/BooleanCellRenderer.js +15 -0
  77. package/dist/components/table/cellRenderers/BooleanCellRenderer.js.map +1 -0
  78. package/dist/components/table/cellRenderers/BooleanCellRenderer.test.d.ts +2 -0
  79. package/dist/components/table/cellRenderers/BooleanCellRenderer.test.d.ts.map +1 -0
  80. package/dist/components/table/cellRenderers/BooleanCellRenderer.test.js +31 -0
  81. package/dist/components/table/cellRenderers/BooleanCellRenderer.test.js.map +1 -0
  82. package/dist/index.css +258 -3
  83. package/dist/index.css.map +1 -1
  84. package/dist/index.d.ts +3 -0
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +2 -0
  87. package/dist/index.js.map +1 -1
  88. package/package.json +1 -1
  89. package/src/components/button/Button.tsx +2 -1
  90. package/src/components/combobox/Combobox.test.tsx +104 -61
  91. package/src/components/combobox/Combobox.tsx +3 -1
  92. package/src/components/combobox/useComboboxPopoverBehavior.ts +10 -5
  93. package/src/components/combobox/useComboboxState.ts +4 -1
  94. package/src/components/datePicker/DatePicker.stories.tsx +67 -9
  95. package/src/components/datePicker/DatePicker.test.tsx +157 -72
  96. package/src/components/datePicker/DatePicker.tsx +163 -69
  97. package/src/components/datePicker/DatePickerCalendarHeader.tsx +82 -0
  98. package/src/components/datePicker/date-field-hint.scss +152 -0
  99. package/src/components/datePicker/dateInputUtils.ts +117 -0
  100. package/src/components/datePicker/datePicker.scss +53 -29
  101. package/src/components/datePicker/datePickerTestUtils.test-helpers.ts +6 -0
  102. package/src/components/dateTimePicker/DateTimePicker.stories.tsx +202 -0
  103. package/src/components/dateTimePicker/DateTimePicker.test.tsx +295 -0
  104. package/src/components/dateTimePicker/DateTimePicker.tsx +293 -0
  105. package/src/components/dateTimePicker/dateTimePicker.scss +17 -0
  106. package/src/components/formField/FormField.test.tsx +5 -5
  107. package/src/components/formField/inputs/selectDropdown/SelectDropdown.test.tsx +28 -0
  108. package/src/components/formField/inputs/selectDropdown/SelectDropdown.tsx +8 -2
  109. package/src/components/formField/inputs/text/TextInput.tsx +6 -3
  110. package/src/components/table/Table.stories.tsx +48 -0
  111. package/src/components/table/Table.tsx +2 -0
  112. package/src/components/table/cellRenderers/BooleanCellRenderer.test.tsx +37 -0
  113. package/src/components/table/cellRenderers/BooleanCellRenderer.tsx +34 -0
  114. package/src/components/table/cellRenderers/booleanCellRenderer.scss +7 -0
  115. package/src/index.scss +2 -0
  116. package/src/index.ts +3 -0
@@ -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
+ };
@@ -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
+ };