@fragments-sdk/ui 0.7.4 → 0.8.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 (162) hide show
  1. package/README.md +58 -25
  2. package/fragments.json +1 -1
  3. package/package.json +22 -5
  4. package/src/blocks/AppShell.block.ts +2 -2
  5. package/src/blocks/InsetDashboardLayout.block.ts +1 -1
  6. package/src/blocks/LoginForm.block.ts +14 -7
  7. package/src/components/Accordion/Accordion.fragment.tsx +8 -2
  8. package/src/components/Accordion/Accordion.test.tsx +171 -0
  9. package/src/components/Alert/Alert.module.scss +4 -4
  10. package/src/components/Alert/Alert.test.tsx +127 -0
  11. package/src/components/AppShell/AppShell.fragment.tsx +1 -1
  12. package/src/components/AppShell/AppShell.test.tsx +80 -0
  13. package/src/components/AppShell/index.tsx +2 -0
  14. package/src/components/Avatar/Avatar.fragment.tsx +5 -1
  15. package/src/components/Avatar/Avatar.module.scss +1 -1
  16. package/src/components/Avatar/Avatar.test.tsx +40 -0
  17. package/src/components/Avatar/index.tsx +37 -1
  18. package/src/components/Badge/Badge.fragment.tsx +3 -3
  19. package/src/components/Badge/Badge.module.scss +4 -4
  20. package/src/components/Badge/Badge.test.tsx +58 -0
  21. package/src/components/Badge/index.tsx +5 -1
  22. package/src/components/Box/Box.test.tsx +43 -0
  23. package/src/components/Box/index.tsx +5 -1
  24. package/src/components/Breadcrumbs/Breadcrumbs.test.tsx +75 -0
  25. package/src/components/Button/Button.fragment.tsx +17 -16
  26. package/src/components/Button/Button.test.tsx +53 -0
  27. package/src/components/Button/index.tsx +5 -1
  28. package/src/components/ButtonGroup/ButtonGroup.test.tsx +44 -0
  29. package/src/components/ButtonGroup/index.tsx +5 -1
  30. package/src/components/Card/Card.fragment.tsx +5 -5
  31. package/src/components/Card/Card.test.tsx +71 -0
  32. package/src/components/Chart/Chart.fragment.tsx +9 -1
  33. package/src/components/Chart/Chart.test.tsx +123 -0
  34. package/src/components/Chart/index.tsx +22 -4
  35. package/src/components/Checkbox/Checkbox.test.tsx +63 -0
  36. package/src/components/Checkbox/index.tsx +5 -1
  37. package/src/components/Chip/Chip.fragment.tsx +0 -5
  38. package/src/components/Chip/Chip.module.scss +55 -2
  39. package/src/components/Chip/Chip.test.tsx +50 -0
  40. package/src/components/CodeBlock/CodeBlock.fragment.tsx +9 -3
  41. package/src/components/CodeBlock/CodeBlock.module.scss +1 -1
  42. package/src/components/CodeBlock/CodeBlock.test.tsx +78 -0
  43. package/src/components/Collapsible/Collapsible.test.tsx +103 -0
  44. package/src/components/ColorPicker/ColorPicker.test.tsx +55 -0
  45. package/src/components/ColorPicker/index.tsx +9 -2
  46. package/src/components/Combobox/Combobox.fragment.tsx +15 -7
  47. package/src/components/Combobox/Combobox.test.tsx +202 -0
  48. package/src/components/ConversationList/ConversationList.fragment.tsx +3 -3
  49. package/src/components/ConversationList/ConversationList.module.scss +1 -1
  50. package/src/components/ConversationList/ConversationList.test.tsx +79 -0
  51. package/src/components/DatePicker/DatePicker.fragment.tsx +245 -0
  52. package/src/components/DatePicker/DatePicker.module.scss +394 -0
  53. package/src/components/DatePicker/DatePicker.test.tsx +264 -0
  54. package/src/components/DatePicker/index.tsx +535 -0
  55. package/src/components/Dialog/Dialog.test.tsx +277 -0
  56. package/src/components/EmptyState/EmptyState.test.tsx +67 -0
  57. package/src/components/Field/Field.fragment.tsx +5 -4
  58. package/src/components/Field/Field.test.tsx +65 -0
  59. package/src/components/Fieldset/Fieldset.fragment.tsx +5 -4
  60. package/src/components/Fieldset/Fieldset.test.tsx +48 -0
  61. package/src/components/Form/Form.fragment.tsx +9 -3
  62. package/src/components/Form/Form.test.tsx +41 -0
  63. package/src/components/Form/index.tsx +5 -1
  64. package/src/components/Grid/Grid.fragment.tsx +4 -0
  65. package/src/components/Grid/Grid.test.tsx +65 -0
  66. package/src/components/Header/Header.fragment.tsx +36 -13
  67. package/src/components/Header/Header.module.scss +114 -1
  68. package/src/components/Header/Header.test.tsx +188 -0
  69. package/src/components/Header/index.tsx +100 -31
  70. package/src/components/Icon/Icon.fragment.tsx +6 -1
  71. package/src/components/Icon/Icon.test.tsx +38 -0
  72. package/src/components/Icon/index.tsx +5 -1
  73. package/src/components/Image/Image.fragment.tsx +2 -2
  74. package/src/components/Image/Image.test.tsx +39 -0
  75. package/src/components/Image/index.tsx +5 -1
  76. package/src/components/Input/Input.fragment.tsx +21 -3
  77. package/src/components/Input/Input.module.scss +1 -1
  78. package/src/components/Input/Input.test.tsx +72 -0
  79. package/src/components/Input/index.tsx +5 -1
  80. package/src/components/Link/Link.fragment.tsx +0 -4
  81. package/src/components/Link/Link.test.tsx +37 -0
  82. package/src/components/Link/index.tsx +5 -1
  83. package/src/components/List/List.test.tsx +57 -0
  84. package/src/components/Listbox/Listbox.fragment.tsx +0 -12
  85. package/src/components/Listbox/Listbox.module.scss +2 -1
  86. package/src/components/Listbox/Listbox.test.tsx +100 -0
  87. package/src/components/Listbox/index.tsx +26 -3
  88. package/src/components/Loading/Loading.test.tsx +38 -0
  89. package/src/components/Markdown/Markdown.module.scss +6 -3
  90. package/src/components/Markdown/Markdown.test.tsx +41 -0
  91. package/src/components/Markdown/index.tsx +5 -1
  92. package/src/components/Menu/Menu.test.tsx +336 -0
  93. package/src/components/Message/Message.fragment.tsx +8 -6
  94. package/src/components/Message/Message.module.scss +1 -1
  95. package/src/components/Message/Message.test.tsx +75 -0
  96. package/src/components/Popover/Popover.test.tsx +105 -0
  97. package/src/components/Progress/Progress.fragment.tsx +14 -0
  98. package/src/components/Progress/Progress.test.tsx +58 -0
  99. package/src/components/Progress/index.tsx +9 -2
  100. package/src/components/Prompt/Prompt.fragment.tsx +11 -0
  101. package/src/components/Prompt/Prompt.test.tsx +89 -0
  102. package/src/components/RadioGroup/RadioGroup.fragment.tsx +5 -0
  103. package/src/components/RadioGroup/RadioGroup.test.tsx +105 -0
  104. package/src/components/ScrollArea/ScrollArea.fragment.tsx +185 -0
  105. package/src/components/ScrollArea/ScrollArea.module.scss +136 -0
  106. package/src/components/ScrollArea/ScrollArea.test.tsx +38 -0
  107. package/src/components/ScrollArea/index.tsx +121 -0
  108. package/src/components/Select/Select.fragment.tsx +13 -5
  109. package/src/components/Select/Select.test.tsx +161 -0
  110. package/src/components/Separator/Separator.test.tsx +33 -0
  111. package/src/components/Separator/index.tsx +5 -1
  112. package/src/components/Sidebar/Sidebar.fragment.tsx +64 -11
  113. package/src/components/Sidebar/Sidebar.module.scss +68 -16
  114. package/src/components/Sidebar/Sidebar.test.tsx +114 -0
  115. package/src/components/Sidebar/index.tsx +69 -45
  116. package/src/components/Skeleton/Skeleton.fragment.tsx +5 -0
  117. package/src/components/Skeleton/Skeleton.test.tsx +56 -0
  118. package/src/components/Slider/Slider.test.tsx +51 -0
  119. package/src/components/Slider/index.tsx +5 -1
  120. package/src/components/Stack/Stack.fragment.tsx +2 -2
  121. package/src/components/Stack/Stack.test.tsx +47 -0
  122. package/src/components/Stack/index.tsx +5 -1
  123. package/src/components/Table/Table.fragment.tsx +29 -0
  124. package/src/components/Table/Table.test.tsx +129 -0
  125. package/src/components/Table/index.tsx +6 -1
  126. package/src/components/TableOfContents/TableOfContents.fragment.tsx +149 -0
  127. package/src/components/TableOfContents/TableOfContents.module.scss +71 -0
  128. package/src/components/TableOfContents/TableOfContents.test.tsx +126 -0
  129. package/src/components/TableOfContents/index.tsx +105 -0
  130. package/src/components/Tabs/Tabs.test.tsx +180 -0
  131. package/src/components/Text/Text.test.tsx +40 -0
  132. package/src/components/Text/index.tsx +5 -1
  133. package/src/components/Textarea/Textarea.fragment.tsx +8 -0
  134. package/src/components/Textarea/Textarea.test.tsx +57 -0
  135. package/src/components/Textarea/index.tsx +5 -1
  136. package/src/components/Theme/Theme.test.tsx +114 -0
  137. package/src/components/Theme/index.tsx +7 -0
  138. package/src/components/ThinkingIndicator/ThinkingIndicator.fragment.tsx +3 -2
  139. package/src/components/ThinkingIndicator/ThinkingIndicator.test.tsx +54 -0
  140. package/src/components/Toast/Toast.fragment.tsx +12 -0
  141. package/src/components/Toast/Toast.test.tsx +192 -0
  142. package/src/components/Toast/index.tsx +14 -4
  143. package/src/components/Toggle/Toggle.test.tsx +49 -0
  144. package/src/components/Toggle/index.tsx +5 -1
  145. package/src/components/ToggleGroup/ToggleGroup.fragment.tsx +96 -78
  146. package/src/components/ToggleGroup/ToggleGroup.test.tsx +90 -0
  147. package/src/components/ToggleGroup/index.tsx +17 -2
  148. package/src/components/Tooltip/Tooltip.fragment.tsx +18 -0
  149. package/src/components/Tooltip/Tooltip.test.tsx +107 -0
  150. package/src/components/Tooltip/index.tsx +6 -1
  151. package/src/components/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
  152. package/src/components/VisuallyHidden/index.tsx +5 -1
  153. package/src/components/compound-pattern.test.ts +40 -0
  154. package/src/index.ts +29 -0
  155. package/src/recipes/AppShell.recipe.ts +2 -2
  156. package/src/recipes/LoginForm.recipe.ts +14 -7
  157. package/src/test/setup.ts +74 -0
  158. package/src/test/utils.tsx +71 -0
  159. package/src/tokens/_computed.scss +12 -0
  160. package/src/tokens/_derive.scss +71 -0
  161. package/src/tokens/_variables.scss +22 -0
  162. package/src/utils/a11y.test.tsx +79 -0
@@ -0,0 +1,394 @@
1
+ @use '../../tokens/variables' as *;
2
+ @use '../../tokens/mixins' as *;
3
+
4
+ // ============================================
5
+ // Trigger
6
+ // ============================================
7
+
8
+ .trigger {
9
+ @include button-reset;
10
+ @include interactive-base;
11
+ @include text-base;
12
+
13
+ display: inline-flex;
14
+ align-items: center;
15
+ gap: var(--fui-space-2, $fui-space-2);
16
+ width: 100%;
17
+ min-width: 10rem;
18
+ height: var(--fui-input-height, $fui-input-height);
19
+ padding: 0 var(--fui-space-3, $fui-space-3);
20
+ background-color: var(--fui-bg-elevated, $fui-bg-elevated);
21
+ border: 1px solid var(--fui-border-strong, $fui-border-strong);
22
+ border-radius: var(--fui-radius-md, $fui-radius-md);
23
+ text-align: left;
24
+
25
+ &:hover:not([data-disabled]) {
26
+ border-color: var(--fui-text-tertiary, $fui-text-tertiary);
27
+ }
28
+
29
+ &[data-popup-open] {
30
+ border-color: var(--fui-color-accent, $fui-color-accent);
31
+ }
32
+
33
+ &[data-disabled] {
34
+ background-color: var(--fui-bg-tertiary, $fui-bg-tertiary);
35
+ color: var(--fui-text-tertiary, $fui-text-tertiary);
36
+ }
37
+ }
38
+
39
+ .triggerIcon {
40
+ display: flex;
41
+ align-items: center;
42
+ justify-content: center;
43
+ color: var(--fui-text-secondary, $fui-text-secondary);
44
+
45
+ svg {
46
+ width: 1rem;
47
+ height: 1rem;
48
+ }
49
+ }
50
+
51
+ .triggerValue {
52
+ flex: 1;
53
+ min-width: 0;
54
+ overflow: hidden;
55
+ text-overflow: ellipsis;
56
+ white-space: nowrap;
57
+ }
58
+
59
+ .triggerPlaceholder {
60
+ flex: 1;
61
+ min-width: 0;
62
+ overflow: hidden;
63
+ text-overflow: ellipsis;
64
+ white-space: nowrap;
65
+ color: var(--fui-text-tertiary, $fui-text-tertiary);
66
+ }
67
+
68
+ // ============================================
69
+ // Positioner & Popup
70
+ // ============================================
71
+
72
+ .positioner {
73
+ z-index: 52;
74
+ outline: none;
75
+ }
76
+
77
+ .popup {
78
+ @include surface-elevated;
79
+
80
+ padding: var(--fui-space-3, $fui-space-3);
81
+ box-shadow: var(--fui-shadow-md, $fui-shadow-md);
82
+
83
+ // Animation
84
+ opacity: 0;
85
+ transform: scale(0.95);
86
+ transform-origin: var(--transform-origin);
87
+ transition:
88
+ opacity var(--fui-transition-fast, $fui-transition-fast),
89
+ transform var(--fui-transition-fast, $fui-transition-fast);
90
+
91
+ &[data-open] {
92
+ opacity: 1;
93
+ transform: scale(1);
94
+ }
95
+
96
+ &[data-starting-style],
97
+ &[data-ending-style] {
98
+ opacity: 0;
99
+ transform: scale(0.95);
100
+ }
101
+ }
102
+
103
+ // ============================================
104
+ // Calendar
105
+ // ============================================
106
+
107
+ .calendar {
108
+ @include text-base;
109
+ }
110
+
111
+ .months {
112
+ display: flex;
113
+ gap: var(--fui-space-4, $fui-space-4);
114
+ }
115
+
116
+ .month {
117
+ // Individual month container
118
+ }
119
+
120
+ .monthCaption {
121
+ display: flex;
122
+ align-items: center;
123
+ justify-content: center;
124
+ padding-bottom: var(--fui-space-2, $fui-space-2);
125
+ }
126
+
127
+ .captionLabel {
128
+ font-size: var(--fui-font-size-sm, $fui-font-size-sm);
129
+ font-weight: var(--fui-font-weight-medium, $fui-font-weight-medium);
130
+ color: var(--fui-text-primary, $fui-text-primary);
131
+ }
132
+
133
+ // ============================================
134
+ // Navigation
135
+ // ============================================
136
+
137
+ .nav {
138
+ display: flex;
139
+ align-items: center;
140
+ justify-content: space-between;
141
+ position: absolute;
142
+ top: 0;
143
+ left: 0;
144
+ right: 0;
145
+ padding: 0;
146
+ }
147
+
148
+ .navButton {
149
+ @include button-reset;
150
+ @include interactive-base;
151
+
152
+ display: inline-flex;
153
+ align-items: center;
154
+ justify-content: center;
155
+ width: var(--fui-button-height-sm, $fui-button-height-sm);
156
+ height: var(--fui-button-height-sm, $fui-button-height-sm);
157
+ border-radius: var(--fui-radius-md, $fui-radius-md);
158
+ color: var(--fui-text-secondary, $fui-text-secondary);
159
+
160
+ &:hover {
161
+ background-color: var(--fui-bg-hover, $fui-bg-hover);
162
+ color: var(--fui-text-primary, $fui-text-primary);
163
+ }
164
+
165
+ svg {
166
+ width: 1rem;
167
+ height: 1rem;
168
+ }
169
+ }
170
+
171
+ .chevron {
172
+ display: none; // We use custom chevron icons
173
+ }
174
+
175
+ // ============================================
176
+ // Grid
177
+ // ============================================
178
+
179
+ .monthGrid {
180
+ border-collapse: collapse;
181
+ border-spacing: 0;
182
+ }
183
+
184
+ .weekdays {
185
+ // Weekday header row
186
+ }
187
+
188
+ .weekday {
189
+ @include text-base;
190
+
191
+ width: 2.286rem;
192
+ height: 2.286rem;
193
+ padding: 0;
194
+ font-size: var(--fui-font-size-xs, $fui-font-size-xs);
195
+ font-weight: var(--fui-font-weight-medium, $fui-font-weight-medium);
196
+ color: var(--fui-text-tertiary, $fui-text-tertiary);
197
+ text-align: center;
198
+ vertical-align: middle;
199
+ }
200
+
201
+ .weeks {
202
+ // Weeks container
203
+ }
204
+
205
+ .week {
206
+ // Week row
207
+ }
208
+
209
+ // ============================================
210
+ // Day cells
211
+ // ============================================
212
+
213
+ .day {
214
+ width: 2.286rem;
215
+ height: 2.286rem;
216
+ padding: 0;
217
+ text-align: center;
218
+ vertical-align: middle;
219
+ position: relative;
220
+ }
221
+
222
+ .dayButton {
223
+ @include button-reset;
224
+
225
+ display: inline-flex;
226
+ align-items: center;
227
+ justify-content: center;
228
+ width: 2.286rem;
229
+ height: 2.286rem;
230
+ border-radius: var(--fui-radius-md, $fui-radius-md);
231
+ font-size: var(--fui-font-size-sm, $fui-font-size-sm);
232
+ color: var(--fui-text-primary, $fui-text-primary);
233
+ transition: background-color var(--fui-transition-fast, $fui-transition-fast),
234
+ color var(--fui-transition-fast, $fui-transition-fast);
235
+
236
+ &:hover {
237
+ background-color: var(--fui-bg-hover, $fui-bg-hover);
238
+ }
239
+
240
+ &:focus-visible {
241
+ @include focus-ring;
242
+ }
243
+ }
244
+
245
+ // ============================================
246
+ // Day states
247
+ // ============================================
248
+
249
+ .today > .dayButton {
250
+ border: 1px solid var(--fui-border-strong, $fui-border-strong);
251
+ }
252
+
253
+ .selected > .dayButton {
254
+ background-color: var(--fui-color-accent, $fui-color-accent);
255
+ color: var(--fui-text-inverse, $fui-text-inverse);
256
+
257
+ &:hover {
258
+ background-color: var(--fui-color-accent-hover, $fui-color-accent-hover);
259
+ }
260
+ }
261
+
262
+ .outside > .dayButton {
263
+ color: var(--fui-text-tertiary, $fui-text-tertiary);
264
+ opacity: 0.5;
265
+ }
266
+
267
+ .disabled > .dayButton {
268
+ color: var(--fui-text-tertiary, $fui-text-tertiary);
269
+ opacity: 0.35;
270
+ pointer-events: none;
271
+ }
272
+
273
+ .focused > .dayButton {
274
+ @include focus-ring;
275
+ }
276
+
277
+ // ============================================
278
+ // Range highlights
279
+ // ============================================
280
+
281
+ .rangeStart,
282
+ .rangeEnd,
283
+ .rangeMiddle {
284
+ position: relative;
285
+ }
286
+
287
+ .rangeMiddle {
288
+ &::before {
289
+ content: '';
290
+ position: absolute;
291
+ inset: 2px 0;
292
+ background-color: var(--fui-bg-hover, $fui-bg-hover);
293
+ z-index: 0;
294
+ }
295
+
296
+ > .dayButton {
297
+ position: relative;
298
+ z-index: 1;
299
+ color: var(--fui-text-primary, $fui-text-primary);
300
+ background-color: transparent;
301
+ }
302
+ }
303
+
304
+ .rangeStart {
305
+ &::before {
306
+ content: '';
307
+ position: absolute;
308
+ inset: 2px 0 2px 50%;
309
+ background-color: var(--fui-bg-hover, $fui-bg-hover);
310
+ z-index: 0;
311
+ }
312
+
313
+ > .dayButton {
314
+ position: relative;
315
+ z-index: 1;
316
+ background-color: var(--fui-color-accent, $fui-color-accent);
317
+ color: var(--fui-text-inverse, $fui-text-inverse);
318
+
319
+ &:hover {
320
+ background-color: var(--fui-color-accent-hover, $fui-color-accent-hover);
321
+ }
322
+ }
323
+
324
+ // When start equals end, no range band
325
+ &.rangeEnd::before {
326
+ display: none;
327
+ }
328
+ }
329
+
330
+ .rangeEnd {
331
+ &::before {
332
+ content: '';
333
+ position: absolute;
334
+ inset: 2px 50% 2px 0;
335
+ background-color: var(--fui-bg-hover, $fui-bg-hover);
336
+ z-index: 0;
337
+ }
338
+
339
+ > .dayButton {
340
+ position: relative;
341
+ z-index: 1;
342
+ background-color: var(--fui-color-accent, $fui-color-accent);
343
+ color: var(--fui-text-inverse, $fui-text-inverse);
344
+
345
+ &:hover {
346
+ background-color: var(--fui-color-accent-hover, $fui-color-accent-hover);
347
+ }
348
+ }
349
+ }
350
+
351
+ // ============================================
352
+ // Preset buttons
353
+ // ============================================
354
+
355
+ .preset {
356
+ @include button-reset;
357
+ @include text-base;
358
+
359
+ display: flex;
360
+ align-items: center;
361
+ width: 100%;
362
+ padding: var(--fui-padding-item-xs, $fui-padding-item-xs) var(--fui-padding-item-md, $fui-padding-item-md);
363
+ border-radius: var(--fui-radius-sm, $fui-radius-sm);
364
+ font-size: var(--fui-font-size-sm, $fui-font-size-sm);
365
+ color: var(--fui-text-primary, $fui-text-primary);
366
+ white-space: nowrap;
367
+ transition: background-color var(--fui-transition-fast, $fui-transition-fast);
368
+
369
+ &:hover {
370
+ background-color: var(--fui-bg-hover, $fui-bg-hover);
371
+ }
372
+
373
+ &:focus-visible {
374
+ @include focus-ring;
375
+ }
376
+ }
377
+
378
+ // ============================================
379
+ // Reduced motion
380
+ // ============================================
381
+
382
+ @media (prefers-reduced-motion: reduce) {
383
+ .popup {
384
+ transition: none;
385
+ }
386
+
387
+ .dayButton {
388
+ transition: none;
389
+ }
390
+
391
+ .preset {
392
+ transition: none;
393
+ }
394
+ }
@@ -0,0 +1,264 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen, userEvent, waitFor, expectNoA11yViolations } from '../../test/utils';
3
+ import { DatePicker } from './index';
4
+ import type { DateRange } from './index';
5
+
6
+ function renderDatePicker(props: {
7
+ onSelect?: (date: Date | null) => void;
8
+ disabled?: boolean;
9
+ selected?: Date | null;
10
+ placeholder?: string;
11
+ } = {}) {
12
+ return render(
13
+ <DatePicker
14
+ placeholder={props.placeholder ?? 'Pick a date'}
15
+ onSelect={props.onSelect}
16
+ disabled={props.disabled}
17
+ selected={props.selected}
18
+ >
19
+ <DatePicker.Trigger />
20
+ <DatePicker.Content>
21
+ <DatePicker.Calendar />
22
+ </DatePicker.Content>
23
+ </DatePicker>
24
+ );
25
+ }
26
+
27
+ function renderRangePicker(props: {
28
+ onRangeSelect?: (range: DateRange | null) => void;
29
+ selectedRange?: DateRange | null;
30
+ numberOfMonths?: number;
31
+ placeholder?: string;
32
+ } = {}) {
33
+ return render(
34
+ <DatePicker
35
+ mode="range"
36
+ placeholder={props.placeholder ?? 'Select date range'}
37
+ onRangeSelect={props.onRangeSelect}
38
+ selectedRange={props.selectedRange}
39
+ numberOfMonths={props.numberOfMonths ?? 2}
40
+ >
41
+ <DatePicker.Trigger />
42
+ <DatePicker.Content>
43
+ <DatePicker.Calendar />
44
+ </DatePicker.Content>
45
+ </DatePicker>
46
+ );
47
+ }
48
+
49
+ describe('DatePicker', () => {
50
+ describe('rendering', () => {
51
+ it('renders a trigger button', () => {
52
+ renderDatePicker();
53
+ expect(screen.getByRole('button')).toBeInTheDocument();
54
+ });
55
+
56
+ it('shows placeholder text when no value selected', () => {
57
+ renderDatePicker({ placeholder: 'Choose date' });
58
+ expect(screen.getByText('Choose date')).toBeInTheDocument();
59
+ });
60
+
61
+ it('shows formatted date when selected', () => {
62
+ renderDatePicker({ selected: new Date(2025, 0, 15) });
63
+ // format(date, 'PPP') produces "January 15th, 2025"
64
+ expect(screen.getByRole('button')).toHaveTextContent('January');
65
+ expect(screen.getByRole('button')).toHaveTextContent('2025');
66
+ });
67
+
68
+ it('shows formatted range when range selected', () => {
69
+ const range: DateRange = {
70
+ from: new Date(2025, 0, 10),
71
+ to: new Date(2025, 0, 20),
72
+ };
73
+ renderRangePicker({ selectedRange: range });
74
+ expect(screen.getByRole('button')).toHaveTextContent('Jan 10, 2025');
75
+ expect(screen.getByRole('button')).toHaveTextContent('Jan 20, 2025');
76
+ });
77
+ });
78
+
79
+ describe('interaction', () => {
80
+ it('opens on click', async () => {
81
+ const user = userEvent.setup();
82
+ renderDatePicker();
83
+
84
+ await user.click(screen.getByRole('button'));
85
+ // DayPicker renders a grid
86
+ expect(await screen.findByRole('grid')).toBeInTheDocument();
87
+ });
88
+
89
+ it('selects a date on click', async () => {
90
+ const user = userEvent.setup();
91
+ const onSelect = vi.fn();
92
+ renderDatePicker({ onSelect });
93
+
94
+ await user.click(screen.getByRole('button'));
95
+ await screen.findByRole('grid');
96
+
97
+ // Click the 15th day button (find any visible "15" in a grid cell)
98
+ const dayButtons = screen.getAllByRole('gridcell');
99
+ const day15 = dayButtons.find((cell) => {
100
+ const btn = cell.querySelector('button');
101
+ return btn?.textContent === '15';
102
+ });
103
+ expect(day15).toBeDefined();
104
+ const btn = day15!.querySelector('button')!;
105
+ await user.click(btn);
106
+
107
+ expect(onSelect).toHaveBeenCalledWith(expect.any(Date));
108
+ });
109
+
110
+ it('auto-closes after single date selection', async () => {
111
+ const user = userEvent.setup();
112
+ renderDatePicker({ onSelect: vi.fn() });
113
+
114
+ await user.click(screen.getByRole('button'));
115
+ await screen.findByRole('grid');
116
+
117
+ const dayButtons = screen.getAllByRole('gridcell');
118
+ const visibleDay = dayButtons.find((cell) => {
119
+ const btn = cell.querySelector('button');
120
+ return btn?.textContent === '10';
121
+ });
122
+ const btn = visibleDay!.querySelector('button')!;
123
+ await user.click(btn);
124
+
125
+ await waitFor(() => {
126
+ expect(screen.queryByRole('grid')).not.toBeInTheDocument();
127
+ }, { timeout: 500 });
128
+ });
129
+
130
+ it('range mode: stays open after both clicks (no auto-close)', async () => {
131
+ const user = userEvent.setup();
132
+ const onRangeSelect = vi.fn();
133
+ renderRangePicker({ onRangeSelect, numberOfMonths: 1 });
134
+
135
+ await user.click(screen.getByRole('button'));
136
+ await screen.findByRole('grid');
137
+
138
+ const dayButtons = screen.getAllByRole('gridcell');
139
+ const day10 = dayButtons.find((cell) => {
140
+ const btn = cell.querySelector('button');
141
+ return btn?.textContent === '10';
142
+ });
143
+ await user.click(day10!.querySelector('button')!);
144
+
145
+ // Should still be open after first click
146
+ expect(screen.getByRole('grid')).toBeInTheDocument();
147
+
148
+ const day20 = dayButtons.find((cell) => {
149
+ const btn = cell.querySelector('button');
150
+ return btn?.textContent === '20';
151
+ });
152
+ await user.click(day20!.querySelector('button')!);
153
+
154
+ // Range mode never auto-closes — user closes via Escape or click-outside
155
+ expect(screen.getByRole('grid')).toBeInTheDocument();
156
+ });
157
+
158
+ it('preset click selects a date', async () => {
159
+ const user = userEvent.setup();
160
+ const onSelect = vi.fn();
161
+ const presetDate = new Date(2025, 5, 1);
162
+
163
+ render(
164
+ <DatePicker onSelect={onSelect}>
165
+ <DatePicker.Trigger placeholder="Pick a date" />
166
+ <DatePicker.Content>
167
+ <DatePicker.Preset date={presetDate}>June 1st</DatePicker.Preset>
168
+ <DatePicker.Calendar />
169
+ </DatePicker.Content>
170
+ </DatePicker>
171
+ );
172
+
173
+ await user.click(screen.getByRole('button', { name: /pick a date/i }));
174
+ await user.click(await screen.findByText('June 1st'));
175
+
176
+ expect(onSelect).toHaveBeenCalledWith(presetDate);
177
+ });
178
+ });
179
+
180
+ describe('keyboard', () => {
181
+ it('Escape closes the calendar', async () => {
182
+ const user = userEvent.setup();
183
+ renderDatePicker();
184
+
185
+ await user.click(screen.getByRole('button'));
186
+ await screen.findByRole('grid');
187
+
188
+ await user.keyboard('{Escape}');
189
+ await waitFor(() => {
190
+ expect(screen.queryByRole('grid')).not.toBeInTheDocument();
191
+ });
192
+ });
193
+ });
194
+
195
+ describe('disabled', () => {
196
+ it('trigger is disabled when disabled prop is true', () => {
197
+ renderDatePicker({ disabled: true });
198
+ expect(screen.getByRole('button')).toBeDisabled();
199
+ });
200
+ });
201
+
202
+ describe('controlled', () => {
203
+ it('reflects external selected value', () => {
204
+ const date = new Date(2025, 2, 20);
205
+ renderDatePicker({ selected: date });
206
+ expect(screen.getByRole('button')).toHaveTextContent('March');
207
+ expect(screen.getByRole('button')).toHaveTextContent('2025');
208
+ });
209
+
210
+ it('reflects external selectedRange value', () => {
211
+ const range: DateRange = {
212
+ from: new Date(2025, 3, 1),
213
+ to: new Date(2025, 3, 7),
214
+ };
215
+ renderRangePicker({ selectedRange: range });
216
+ expect(screen.getByRole('button')).toHaveTextContent('Apr 01, 2025');
217
+ expect(screen.getByRole('button')).toHaveTextContent('Apr 07, 2025');
218
+ });
219
+ });
220
+
221
+ describe('multi-month', () => {
222
+ it('renders correct number of month panels', async () => {
223
+ const user = userEvent.setup();
224
+ renderRangePicker({ numberOfMonths: 2 });
225
+
226
+ await user.click(screen.getByRole('button'));
227
+ const grids = await screen.findAllByRole('grid');
228
+ expect(grids).toHaveLength(2);
229
+ });
230
+ });
231
+
232
+ describe('a11y', () => {
233
+ it('has no accessibility violations (closed)', async () => {
234
+ const { container } = render(
235
+ <DatePicker>
236
+ <DatePicker.Trigger aria-label="Pick a date" placeholder="Pick a date" />
237
+ <DatePicker.Content>
238
+ <DatePicker.Calendar />
239
+ </DatePicker.Content>
240
+ </DatePicker>
241
+ );
242
+ await expectNoA11yViolations(container);
243
+ });
244
+
245
+ it('has no accessibility violations (open)', async () => {
246
+ const user = userEvent.setup();
247
+ const { container } = render(
248
+ <DatePicker>
249
+ <DatePicker.Trigger aria-label="Pick a date" placeholder="Pick a date" />
250
+ <DatePicker.Content aria-label="Choose date">
251
+ <DatePicker.Calendar />
252
+ </DatePicker.Content>
253
+ </DatePicker>
254
+ );
255
+
256
+ await user.click(screen.getByRole('button'));
257
+ await screen.findByRole('grid');
258
+ // Disable aria-command-name: Base UI focus guard spans have role="button" without names (upstream)
259
+ await expectNoA11yViolations(container, {
260
+ disabledRules: ['aria-command-name'],
261
+ });
262
+ });
263
+ });
264
+ });