@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
@@ -2,7 +2,7 @@ import classNames from 'classnames';
2
2
  import { Icon } from 'Components/icon/Icon';
3
3
  import { Tag } from 'Components/tag/Tag';
4
4
  import { Popover } from 'radix-ui';
5
- import type { ComboboxAriaInvalid, ComboboxOption } from './types';
5
+ import type { ComboboxAriaInvalid, ComboboxOption, ComboboxSelectedValueDisplay } from './types';
6
6
 
7
7
  export type ComboboxTriggerProps = {
8
8
  'inputRef': React.RefObject<HTMLInputElement | null>;
@@ -21,6 +21,8 @@ export type ComboboxTriggerProps = {
21
21
  'aria-invalid'?: ComboboxAriaInvalid;
22
22
  'aria-label'?: string;
23
23
  'showDropdownTrigger': boolean;
24
+ 'selectedValueDisplay': ComboboxSelectedValueDisplay;
25
+ 'triggerEndContent'?: React.ReactNode;
24
26
  'selectedChips': ComboboxOption[];
25
27
  'selectedChipValuesSet': Set<string>;
26
28
  'focusedChipIndex': number | null;
@@ -52,6 +54,8 @@ export const ComboboxTrigger = (props: ComboboxTriggerProps): React.JSX.Element
52
54
  'aria-invalid': ariaInvalid,
53
55
  'aria-label': ariaLabel,
54
56
  showDropdownTrigger,
57
+ selectedValueDisplay,
58
+ triggerEndContent,
55
59
  selectedChips,
56
60
  selectedChipValuesSet,
57
61
  focusedChipIndex,
@@ -65,6 +69,12 @@ export const ComboboxTrigger = (props: ComboboxTriggerProps): React.JSX.Element
65
69
  handleChevronClick,
66
70
  } = props;
67
71
 
72
+ const selectedValueText = selectedChips.map(resolveTagLabel).join(', ');
73
+ const showSelectedValueText
74
+ = selectedValueDisplay === 'text'
75
+ && query.length === 0
76
+ && selectedValueText.length > 0;
77
+
68
78
  return (
69
79
  <Popover.Anchor asChild>
70
80
  <div
@@ -77,23 +87,32 @@ export const ComboboxTrigger = (props: ComboboxTriggerProps): React.JSX.Element
77
87
  onClick={handleTriggerClick}
78
88
  >
79
89
  <div className="ds-combobox__chips-and-input">
80
- {selectedChips.map((opt, chipIdx) => (
81
- <Tag
82
- key={opt.value}
83
- color="neutral"
84
- selected={selectedChipValuesSet.has(opt.value) || focusedChipIndex === chipIdx}
85
- slotStart={opt.iconName ? <Icon name={opt.iconName} size={12} /> : undefined}
86
- onRemove={disabled ? undefined : () => removeValue(opt.value)}
87
- removeLabel={`Remove ${resolveTagLabel(opt)}`}
88
- removeButtonTabIndex={-1}
89
- >
90
- {resolveTagLabel(opt)}
91
- </Tag>
92
- ))}
90
+ {selectedValueDisplay === 'tags'
91
+ ? selectedChips.map((opt, chipIdx) => (
92
+ <Tag
93
+ key={opt.value}
94
+ color="neutral"
95
+ selected={selectedChipValuesSet.has(opt.value) || focusedChipIndex === chipIdx}
96
+ slotStart={opt.iconName ? <Icon name={opt.iconName} size={12} /> : undefined}
97
+ onRemove={disabled ? undefined : () => removeValue(opt.value)}
98
+ removeLabel={`Remove ${resolveTagLabel(opt)}`}
99
+ removeButtonTabIndex={-1}
100
+ >
101
+ {resolveTagLabel(opt)}
102
+ </Tag>
103
+ ))
104
+ : null}
105
+ {showSelectedValueText && (
106
+ <span className="ds-combobox__selected-value">
107
+ {selectedValueText}
108
+ </span>
109
+ )}
93
110
  <input
94
111
  ref={inputRef}
95
112
  id={comboboxId}
96
- className="ds-combobox__input"
113
+ className={classNames('ds-combobox__input', {
114
+ 'ds-combobox__input--with-selected-value': showSelectedValueText,
115
+ })}
97
116
  type="text"
98
117
  role="combobox"
99
118
  aria-autocomplete="list"
@@ -114,6 +133,11 @@ export const ComboboxTrigger = (props: ComboboxTriggerProps): React.JSX.Element
114
133
  autoComplete="off"
115
134
  />
116
135
  </div>
136
+ {triggerEndContent && (
137
+ <span className="ds-combobox__end-content" aria-hidden="true">
138
+ {triggerEndContent}
139
+ </span>
140
+ )}
117
141
  {showDropdownTrigger && (
118
142
  <button
119
143
  type="button"
@@ -88,6 +88,13 @@
88
88
  white-space: nowrap;
89
89
  }
90
90
 
91
+ .ds-combobox__button-value {
92
+ min-width: 0;
93
+ overflow: hidden;
94
+ text-overflow: ellipsis;
95
+ white-space: nowrap;
96
+ }
97
+
91
98
  .ds-combobox__button-ellipsis {
92
99
  flex-shrink: 0;
93
100
  font-size: var(--font-size-medium);
@@ -127,6 +134,10 @@
127
134
  }
128
135
  }
129
136
 
137
+ .ds-combobox__input--with-selected-value {
138
+ min-width: 1ch;
139
+ }
140
+
130
141
  // Chevron
131
142
  .ds-combobox__chevron {
132
143
  flex-shrink: 0;
@@ -144,6 +155,13 @@
144
155
  }
145
156
  }
146
157
 
158
+ .ds-combobox__end-content {
159
+ flex-shrink: 0;
160
+ display: inline-flex;
161
+ align-items: center;
162
+ justify-content: center;
163
+ }
164
+
147
165
  // Popover
148
166
  .ds-combobox__popover {
149
167
  min-width: var(--radix-popper-anchor-width);
@@ -16,6 +16,7 @@ export type ComboboxOption = {
16
16
  export type ComboboxCreateResult = string | ComboboxOption | void;
17
17
  export type ComboboxSearchFn = (option: ComboboxOption, query: string) => boolean;
18
18
  export type ComboboxSearchType = 'prefix' | 'substring' | ComboboxSearchFn;
19
+ export type ComboboxSelectedValueDisplay = 'tags' | 'text';
19
20
 
20
21
  export type ComboboxProps = {
21
22
  'options': ComboboxOption[];
@@ -36,7 +37,9 @@ export type ComboboxProps = {
36
37
  'disabled'?: boolean;
37
38
  'dropdownOnFocus'?: boolean;
38
39
  'triggerVariant'?: 'input' | 'button';
40
+ 'selectedValueDisplay'?: ComboboxSelectedValueDisplay;
39
41
  'showDropdownTrigger'?: boolean;
42
+ 'triggerEndContent'?: React.ReactNode;
40
43
  'showSelectionCountBadge'?: boolean;
41
44
  'selectionCountA11yLabel'?: string | ((count: number) => string);
42
45
  'loading'?: boolean;
@@ -26,6 +26,8 @@ type UseComboboxPopoverBehaviorParams = {
26
26
  clearChipSelection: () => void;
27
27
  exitChipNav: () => void;
28
28
  triggerContainsInput?: boolean;
29
+ /** Search field is inside the popover (button trigger); keep shell mounted while open even with zero matches. */
30
+ renderSearchInputInListbox: boolean;
29
31
  };
30
32
 
31
33
  export type UseComboboxPopoverBehaviorReturn = {
@@ -61,10 +63,13 @@ export const useComboboxPopoverBehavior = ({
61
63
  clearChipSelection,
62
64
  exitChipNav,
63
65
  triggerContainsInput = true,
66
+ renderSearchInputInListbox,
64
67
  }: UseComboboxPopoverBehaviorParams): UseComboboxPopoverBehaviorReturn => {
65
- const shouldRenderPopoverContent = loading || totalItems > 0;
68
+ const shouldRenderPopoverContent
69
+ = loading || totalItems > 0 || (renderSearchInputInListbox && isOpen);
66
70
  const shouldShowPopover = isOpen && shouldRenderPopoverContent;
67
71
  const showListboxLoading = Boolean(loading && shouldShowPopover);
72
+ const canOpenPopover = loading || totalItems > 0 || renderSearchInputInListbox;
68
73
 
69
74
  const handleInputChange = useCallback(
70
75
  (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -110,10 +115,10 @@ export const useComboboxPopoverBehavior = ({
110
115
 
111
116
  const requestOpen = useCallback(() => {
112
117
  if (disabled) return;
113
- if (shouldRenderPopoverContent) {
118
+ if (canOpenPopover) {
114
119
  openPopover();
115
120
  }
116
- }, [disabled, openPopover, shouldRenderPopoverContent]);
121
+ }, [disabled, openPopover, canOpenPopover]);
117
122
 
118
123
  const handleTriggerClick = useCallback(() => {
119
124
  if (disabled) return;
@@ -148,11 +153,11 @@ export const useComboboxPopoverBehavior = ({
148
153
  if (triggerContainsInput) {
149
154
  inputRef.current?.focus();
150
155
  }
151
- if (shouldRenderPopoverContent) {
156
+ if (canOpenPopover) {
152
157
  openPopover();
153
158
  }
154
159
  },
155
- [disabled, inputRef, openPopover, shouldRenderPopoverContent, triggerContainsInput],
160
+ [disabled, inputRef, openPopover, canOpenPopover, triggerContainsInput],
156
161
  );
157
162
 
158
163
  const handlePopoverInteractOutside = useCallback(
@@ -129,8 +129,11 @@ export const useComboboxState = (params: UseComboboxStateParams): UseComboboxSta
129
129
  : [optionValue];
130
130
  updateValue(next);
131
131
  setQuery('');
132
+ if (!multiple) {
133
+ setIsOpen(false);
134
+ }
132
135
  },
133
- [multiple, selectedValues, selectedValuesSet, updateValue],
136
+ [multiple, selectedValues, selectedValuesSet, setIsOpen, updateValue],
134
137
  );
135
138
 
136
139
  const removeValue = useCallback(
@@ -5,17 +5,35 @@ import { DatePicker } from './DatePicker';
5
5
  const meta = {
6
6
  title: 'Components/DatePicker',
7
7
  component: DatePicker,
8
+ decorators: [
9
+ Story => (
10
+ <div style={{ maxWidth: '220px', width: '100%' }}>
11
+ <Story />
12
+ </div>
13
+ ),
14
+ ],
8
15
  parameters: {
9
16
  layout: 'centered',
17
+ docs: {
18
+ description: {
19
+ component:
20
+ '`DatePicker` uses a native `type="date"` input (`yyyy-MM-dd`) with a synced calendar popover. When empty, hint copy uses a decorative span (browsers do not reliably show `placeholder` on native date fields). `displayFormat` controls that hint (`DD/MM/YYYY` vs `Pick a date`). The custom month/year header uses the design-system select control.',
21
+ },
22
+ },
10
23
  },
11
24
  tags: ['autodocs'],
12
25
  args: {
13
26
  onChange: fn(),
14
27
  },
15
28
  argTypes: {
16
- dateFormat: {
29
+ displayFormat: {
30
+ control: 'inline-radio',
31
+ options: ['default', 'friendly'],
32
+ description: 'Controls the empty-state hint text (`DD/MM/YYYY` vs `Pick a date`). The field value is always native ISO `yyyy-MM-dd`.',
33
+ },
34
+ placeholder: {
17
35
  control: 'text',
18
- description: 'Date format string using date-fns tokens (e.g. "dd/MM/yyyy", "MM/dd/yyyy", "yyyy-MM-dd")',
36
+ description: 'Optional override for the empty-state hint (defaults from `displayFormat`).',
19
37
  },
20
38
  className: {
21
39
  control: 'text',
@@ -30,18 +48,58 @@ const meta = {
30
48
  export default meta;
31
49
  type Story = StoryObj<typeof meta>;
32
50
 
33
- export const Default: Story = {};
51
+ export const Default: Story = {
52
+ parameters: {
53
+ docs: {
54
+ description: {
55
+ story: 'Empty field shows hint **DD/MM/YYYY** (`displayFormat="default"`).',
56
+ },
57
+ },
58
+ },
59
+ };
34
60
 
35
- export const USDateFormat: Story = {
36
- name: 'US Date Format (MM/dd/yyyy)',
61
+ export const FriendlyFormat: Story = {
62
+ name: 'Friendly Format',
37
63
  args: {
38
- dateFormat: 'MM/dd/yyyy',
64
+ displayFormat: 'friendly',
65
+ },
66
+ parameters: {
67
+ docs: {
68
+ description: {
69
+ story: 'Empty field shows hint **Pick a date**.',
70
+ },
71
+ },
39
72
  },
40
73
  };
41
74
 
42
- export const ISOFormat: Story = {
43
- name: 'ISO Format (yyyy-MM-dd)',
75
+ /** `placeholder` overrides the default empty-state hint. */
76
+ export const PlaceholderCustomOverride: Story = {
77
+ name: 'Placeholder · custom copy',
44
78
  args: {
45
- dateFormat: 'yyyy-MM-dd',
79
+ displayFormat: 'default',
80
+ placeholder: 'e.g. 25/12/2024',
81
+ },
82
+ parameters: {
83
+ docs: {
84
+ description: {
85
+ story: 'The optional `placeholder` prop replaces the default hint.',
86
+ },
87
+ },
88
+ },
89
+ };
90
+
91
+ /** Friendly format with custom hint copy. */
92
+ export const PlaceholderFriendlyWithCustomCopy: Story = {
93
+ name: 'Placeholder · friendly format, custom hint',
94
+ args: {
95
+ displayFormat: 'friendly',
96
+ placeholder: 'Choose a date…',
97
+ },
98
+ parameters: {
99
+ docs: {
100
+ description: {
101
+ story: '`displayFormat="friendly"` with a custom hint instead of **Pick a date**.',
102
+ },
103
+ },
46
104
  },
47
105
  };
@@ -2,18 +2,22 @@ import { describe, expect, test, vi } from 'vitest';
2
2
  import { render, screen, within, fireEvent } from '@testing-library/react';
3
3
  import userEvent from '@testing-library/user-event';
4
4
  import '@testing-library/jest-dom/vitest';
5
- import { format } from 'date-fns';
6
5
  import { DatePicker } from './DatePicker';
6
+ import { formatNativeDateInputValue } from './dateInputUtils';
7
+ import { getDropdownTrigger } from './datePickerTestUtils.test-helpers';
8
+
9
+ const getDateField = (container: HTMLElement) => (
10
+ container.querySelector('input[type="date"]') as HTMLInputElement
11
+ );
7
12
 
8
13
  describe('DatePicker', () => {
9
- test('renders a text input', () => {
10
- render(<DatePicker />);
11
- expect(screen.getByRole('textbox')).toBeInTheDocument();
14
+ test('renders a date input', () => {
15
+ const { container } = render(<DatePicker />);
16
+ expect(getDateField(container)).toBeInTheDocument();
12
17
  });
13
18
 
14
19
  test('renders a calendar toggle button', () => {
15
20
  render(<DatePicker />);
16
- // Button has an icon with screenReaderText="date" (from iconLeftName fallback in Button/Icon)
17
21
  expect(screen.getByRole('button', { name: 'Open date picker' })).toBeInTheDocument();
18
22
  });
19
23
 
@@ -22,6 +26,49 @@ describe('DatePicker', () => {
22
26
  expect(screen.queryByRole('application')).not.toBeInTheDocument();
23
27
  });
24
28
 
29
+ test('shows empty-state hint copy when empty (native placeholder is unreliable)', () => {
30
+ const { container } = render(<DatePicker />);
31
+ expect(getDateField(container)).toHaveAttribute('placeholder', '');
32
+ expect(screen.getByText('DD/MM/YYYY')).toBeInTheDocument();
33
+ });
34
+
35
+ test('placeholder prop overrides the empty-state hint', () => {
36
+ const { container } = render(<DatePicker placeholder="When?" />);
37
+ expect(getDateField(container)).toHaveAttribute('placeholder', '');
38
+ expect(screen.getByText('When?')).toBeInTheDocument();
39
+ });
40
+
41
+ test('friendly displayFormat changes the empty-state hint', () => {
42
+ const { container } = render(<DatePicker displayFormat="friendly" />);
43
+ expect(getDateField(container)).toHaveAttribute('placeholder', '');
44
+ expect(screen.getByText('Pick a date')).toBeInTheDocument();
45
+ });
46
+
47
+ describe('custom placeholder empty state', () => {
48
+ test('uses read-only date input and opens the calendar on pointer down', () => {
49
+ const { container } = render(<DatePicker placeholder="Pick one" />);
50
+ const input = getDateField(container);
51
+ expect(input.readOnly).toBe(true);
52
+ fireEvent.pointerDown(input);
53
+ expect(screen.getByRole('application')).toBeInTheDocument();
54
+ });
55
+
56
+ test('does not open the calendar on keyboard focus alone', () => {
57
+ const { container } = render(<DatePicker placeholder="Pick one" />);
58
+ const input = getDateField(container);
59
+ fireEvent.focus(input);
60
+ expect(screen.queryByRole('application')).not.toBeInTheDocument();
61
+ });
62
+
63
+ test('opens the calendar when the field is activated with a click', async () => {
64
+ const user = userEvent.setup();
65
+ const { container } = render(<DatePicker placeholder="Pick one" />);
66
+ const input = getDateField(container);
67
+ await user.click(input);
68
+ expect(screen.getByRole('application')).toBeInTheDocument();
69
+ });
70
+ });
71
+
25
72
  describe('calendar toggle', () => {
26
73
  test('clicking the button opens the day picker', async () => {
27
74
  render(<DatePicker />);
@@ -37,61 +84,57 @@ describe('DatePicker', () => {
37
84
  });
38
85
  });
39
86
 
40
- describe('text input', () => {
41
- // Use fireEvent.change rather than userEvent.type the component's useEffect overwrites
42
- // inputValue state between individual keystrokes, which conflicts with userEvent's tracking.
43
-
44
- test('entering a valid date updates the input value', () => {
45
- render(<DatePicker />);
46
- fireEvent.change(screen.getByRole('textbox'), { target: { value: '15/06/2024' } });
47
- expect(screen.getByRole('textbox')).toHaveValue('15/06/2024');
87
+ describe('date input', () => {
88
+ test('entering a valid ISO date updates the input value', () => {
89
+ const { container } = render(<DatePicker />);
90
+ const input = getDateField(container);
91
+ fireEvent.change(input, { target: { value: '2024-06-15' } });
92
+ expect(input).toHaveValue('2024-06-15');
48
93
  });
49
94
 
50
- test('entering a valid date calls onChange with the parsed Date', () => {
95
+ test('entering a valid ISO date calls onChange with the parsed Date', () => {
51
96
  const onChange = vi.fn();
52
- render(<DatePicker onChange={onChange} />);
53
- onChange.mockClear(); // ignore the initial mount call with undefined
97
+ const { container } = render(<DatePicker onChange={onChange} />);
98
+ const input = getDateField(container);
54
99
 
55
- fireEvent.change(screen.getByRole('textbox'), { target: { value: '15/06/2024' } });
100
+ fireEvent.change(input, { target: { value: '2024-06-15' } });
56
101
 
102
+ expect(onChange).toHaveBeenCalledTimes(1);
57
103
  expect(onChange).toHaveBeenLastCalledWith(expect.any(Date));
58
104
  const lastDate: Date = onChange.mock.lastCall![0];
59
105
  expect(lastDate.getFullYear()).toBe(2024);
60
- expect(lastDate.getMonth()).toBe(5); // June is month index 5
106
+ expect(lastDate.getMonth()).toBe(5);
61
107
  expect(lastDate.getDate()).toBe(15);
108
+ expect(lastDate.getHours()).toBe(0);
109
+ expect(lastDate.getMinutes()).toBe(0);
62
110
  });
63
111
 
64
- test('entering an invalid string after a valid date calls onChange with undefined', () => {
65
- // onChange is only fired by the useEffect when selectedDate actually changes.
66
- // So we first need a valid date to put selectedDate into a non-undefined state.
112
+ test('entering an invalid string calls onChange with undefined', () => {
67
113
  const onChange = vi.fn();
68
- render(<DatePicker onChange={onChange} />);
69
- fireEvent.change(screen.getByRole('textbox'), { target: { value: '15/06/2024' } });
70
- onChange.mockClear();
114
+ const { container } = render(<DatePicker onChange={onChange} />);
115
+ const input = getDateField(container);
71
116
 
72
- fireEvent.change(screen.getByRole('textbox'), { target: { value: 'not-a-date' } });
117
+ fireEvent.change(input, { target: { value: '2024-06-15' } });
118
+ onChange.mockClear();
119
+ fireEvent.change(input, { target: { value: 'not-a-date' } });
73
120
 
121
+ expect(onChange).toHaveBeenCalledTimes(1);
74
122
  expect(onChange).toHaveBeenLastCalledWith(undefined);
75
123
  });
76
124
  });
77
125
 
78
126
  describe('day picker selection', () => {
79
- // Avoid fake timers (incompatible with userEvent) by navigating the picker to a
80
- // known month via the text input before opening the calendar.
81
-
82
127
  test('selecting a date from the picker updates the input', async () => {
83
- render(<DatePicker />);
84
- // Navigate to June 2024 by entering a different day in that month
85
- fireEvent.change(screen.getByRole('textbox'), { target: { value: '01/06/2024' } });
128
+ const { container } = render(<DatePicker />);
129
+ fireEvent.change(getDateField(container), { target: { value: '2024-06-01' } });
86
130
  await userEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
87
- // Day aria-labels use date-fns PPPP format e.g. "Thursday, June 20, 2024"
88
131
  await userEvent.click(within(screen.getByRole('application')).getByRole('button', { name: /June 20/ }));
89
- expect(screen.getByRole('textbox')).toHaveValue('20/06/2024');
132
+ expect(getDateField(container)).toHaveValue('2024-06-20');
90
133
  });
91
134
 
92
135
  test('selecting a date from the picker closes the picker', async () => {
93
- render(<DatePicker />);
94
- fireEvent.change(screen.getByRole('textbox'), { target: { value: '01/06/2024' } });
136
+ const { container } = render(<DatePicker />);
137
+ fireEvent.change(getDateField(container), { target: { value: '2024-06-01' } });
95
138
  await userEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
96
139
  await userEvent.click(within(screen.getByRole('application')).getByRole('button', { name: /June 20/ }));
97
140
  expect(screen.queryByRole('application')).not.toBeInTheDocument();
@@ -99,8 +142,8 @@ describe('DatePicker', () => {
99
142
 
100
143
  test('selecting a date from the picker calls onChange with the selected date', async () => {
101
144
  const onChange = vi.fn();
102
- render(<DatePicker onChange={onChange} />);
103
- fireEvent.change(screen.getByRole('textbox'), { target: { value: '01/06/2024' } });
145
+ const { container } = render(<DatePicker onChange={onChange} />);
146
+ fireEvent.change(getDateField(container), { target: { value: '2024-06-01' } });
104
147
  onChange.mockClear();
105
148
 
106
149
  await userEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
@@ -109,35 +152,100 @@ describe('DatePicker', () => {
109
152
  expect(onChange).toHaveBeenCalledWith(expect.any(Date));
110
153
  const lastDate: Date = onChange.mock.lastCall![0];
111
154
  expect(lastDate.getFullYear()).toBe(2024);
112
- expect(lastDate.getMonth()).toBe(5); // June is month index 5
155
+ expect(lastDate.getMonth()).toBe(5);
113
156
  expect(lastDate.getDate()).toBe(20);
157
+ expect(lastDate.getHours()).toBe(0);
158
+ expect(lastDate.getMinutes()).toBe(0);
114
159
  });
115
160
 
116
- test('clicking the Today button selects today\'s date', async () => {
117
- render(<DatePicker />);
161
+ test('clicking the Today button sets today and closes the picker', async () => {
162
+ const { container } = render(<DatePicker />);
118
163
  await userEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
119
164
  await userEvent.click(screen.getByRole('button', { name: 'Today' }));
120
- expect(screen.getByRole('textbox')).toHaveValue(format(new Date(), 'dd/MM/yyyy'));
165
+ expect(getDateField(container)).toHaveValue(formatNativeDateInputValue(new Date()));
166
+ expect(screen.queryByRole('application')).not.toBeInTheDocument();
121
167
  });
122
168
 
123
- test('clicking the Today button closes the picker', async () => {
124
- render(<DatePicker />);
169
+ test('opening the picker after typing a valid value syncs the month and year controls', async () => {
170
+ const { container } = render(<DatePicker />);
171
+ fireEvent.change(getDateField(container), { target: { value: '2024-06-01' } });
172
+
125
173
  await userEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
126
- await userEvent.click(screen.getByRole('button', { name: 'Today' }));
127
- expect(screen.queryByRole('application')).not.toBeInTheDocument();
174
+
175
+ expect(getDropdownTrigger(/June/i)).toBeInTheDocument();
176
+ expect(getDropdownTrigger(/2024/i)).toBeInTheDocument();
177
+ });
178
+
179
+ test('changing the custom month select updates the displayed calendar month', async () => {
180
+ const user = userEvent.setup();
181
+ const { container } = render(<DatePicker value={new Date(2024, 5, 15)} />);
182
+
183
+ await user.click(screen.getByRole('button', { name: 'Open date picker' }));
184
+ await user.click(getDropdownTrigger(/June/i)!);
185
+ await user.click(screen.getByText('July'));
186
+
187
+ expect(getDropdownTrigger(/July/i)).toBeInTheDocument();
188
+ expect(within(screen.getByRole('application')).getByRole('button', { name: /July 15/ })).toBeInTheDocument();
189
+ expect(getDateField(container)).toHaveValue(formatNativeDateInputValue(new Date(2024, 5, 15)));
190
+ });
191
+ });
192
+
193
+ describe('defaultValue prop', () => {
194
+ test('renders with the defaultValue when no value is provided', () => {
195
+ const { container } = render(<DatePicker defaultValue={new Date(2024, 5, 15)} />);
196
+ expect(getDateField(container)).toHaveValue(formatNativeDateInputValue(new Date(2024, 5, 15)));
197
+ });
198
+
199
+ test('value prop takes precedence over defaultValue', () => {
200
+ const { container } = render(
201
+ <DatePicker value={new Date(2024, 11, 25)} defaultValue={new Date(2024, 5, 15)} />,
202
+ );
203
+ expect(getDateField(container)).toHaveValue(formatNativeDateInputValue(new Date(2024, 11, 25)));
204
+ });
205
+
206
+ test('does not call onChange on mount with defaultValue', () => {
207
+ const onChange = vi.fn();
208
+ render(<DatePicker defaultValue={new Date(2024, 5, 15)} onChange={onChange} />);
209
+ expect(onChange).not.toHaveBeenCalled();
210
+ });
211
+ });
212
+
213
+ describe('onChange call-count correctness', () => {
214
+ test('does not call onChange on mount', () => {
215
+ const onChange = vi.fn();
216
+ render(<DatePicker onChange={onChange} />);
217
+ expect(onChange).not.toHaveBeenCalled();
218
+ });
219
+
220
+ test('does not call onChange on mount with an initial value', () => {
221
+ const onChange = vi.fn();
222
+ render(<DatePicker value={new Date(2024, 5, 15)} onChange={onChange} />);
223
+ expect(onChange).not.toHaveBeenCalled();
224
+ });
225
+
226
+ test('calls onChange exactly once per user interaction', async () => {
227
+ const onChange = vi.fn();
228
+ const { container } = render(<DatePicker onChange={onChange} />);
229
+
230
+ fireEvent.change(getDateField(container), { target: { value: '2024-06-15' } });
231
+ expect(onChange).toHaveBeenCalledTimes(1);
232
+
233
+ await userEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
234
+ await userEvent.click(within(screen.getByRole('application')).getByRole('button', { name: /June 20/ }));
235
+ expect(onChange).toHaveBeenCalledTimes(2);
128
236
  });
129
237
  });
130
238
 
131
239
  describe('controlled value prop', () => {
132
240
  test('renders with the initial value', () => {
133
- render(<DatePicker value={new Date(2024, 5, 15)} />);
134
- expect(screen.getByRole('textbox')).toHaveValue('15/06/2024');
241
+ const { container } = render(<DatePicker value={new Date(2024, 5, 15)} />);
242
+ expect(getDateField(container)).toHaveValue(formatNativeDateInputValue(new Date(2024, 5, 15)));
135
243
  });
136
244
 
137
245
  test('updates the input when value prop changes', () => {
138
- const { rerender } = render(<DatePicker value={new Date(2024, 5, 15)} />);
246
+ const { container, rerender } = render(<DatePicker value={new Date(2024, 5, 15)} />);
139
247
  rerender(<DatePicker value={new Date(2024, 11, 25)} />);
140
- expect(screen.getByRole('textbox')).toHaveValue('25/12/2024');
248
+ expect(getDateField(container)).toHaveValue(formatNativeDateInputValue(new Date(2024, 11, 25)));
141
249
  });
142
250
 
143
251
  test('does not call onChange when value prop changes externally', () => {
@@ -155,7 +263,6 @@ describe('DatePicker', () => {
155
263
  render(<DatePicker value={new Date(2024, 5, 15)} onChange={onChange} />);
156
264
  onChange.mockClear();
157
265
 
158
- // Navigate to June 2024 (already set via value) and pick a different day
159
266
  await userEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
160
267
  await userEvent.click(within(screen.getByRole('application')).getByRole('button', { name: /June 20/ }));
161
268
 
@@ -164,26 +271,4 @@ describe('DatePicker', () => {
164
271
  expect(lastDate.getDate()).toBe(20);
165
272
  });
166
273
  });
167
-
168
- describe('custom dateFormat prop', () => {
169
- test('formats the value according to the custom format', () => {
170
- render(<DatePicker dateFormat="yyyy-MM-dd" />);
171
- fireEvent.change(screen.getByRole('textbox'), { target: { value: '2024-06-15' } });
172
- expect(screen.getByRole('textbox')).toHaveValue('2024-06-15');
173
- });
174
-
175
- test('calls onChange with the correctly parsed date for a custom format', () => {
176
- const onChange = vi.fn();
177
- render(<DatePicker dateFormat="MM/dd/yyyy" onChange={onChange} />);
178
- onChange.mockClear();
179
-
180
- fireEvent.change(screen.getByRole('textbox'), { target: { value: '06/15/2024' } });
181
-
182
- expect(onChange).toHaveBeenLastCalledWith(expect.any(Date));
183
- const lastDate: Date = onChange.mock.lastCall![0];
184
- expect(lastDate.getFullYear()).toBe(2024);
185
- expect(lastDate.getMonth()).toBe(5); // June
186
- expect(lastDate.getDate()).toBe(15);
187
- });
188
- });
189
274
  });