@astryxdesign/core 0.4.5 → 0.4.6-canary.cea9ecd

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 (538) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/dist/Avatar/Avatar.d.ts +10 -5
  3. package/dist/Avatar/Avatar.d.ts.map +1 -1
  4. package/dist/Avatar/Avatar.js +139 -30
  5. package/dist/Avatar/AvatarStatusDot.d.ts +2 -1
  6. package/dist/Avatar/AvatarStatusDot.d.ts.map +1 -1
  7. package/dist/Avatar/AvatarStatusDot.js +30 -4
  8. package/dist/Avatar/AvatarStatusLabelContext.d.ts +39 -0
  9. package/dist/Avatar/AvatarStatusLabelContext.d.ts.map +1 -0
  10. package/dist/Avatar/AvatarStatusLabelContext.js +41 -0
  11. package/dist/AvatarGroup/AvatarGroup.d.ts +3 -1
  12. package/dist/AvatarGroup/AvatarGroup.d.ts.map +1 -1
  13. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  14. package/dist/AvatarGroup/AvatarGroupOverflow.js +15 -9
  15. package/dist/Blockquote/Blockquote.d.ts +4 -3
  16. package/dist/Blockquote/Blockquote.d.ts.map +1 -1
  17. package/dist/Blockquote/Blockquote.js +9 -10
  18. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  19. package/dist/BottomSheet/BottomSheetPanel.js +21 -5
  20. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  21. package/dist/Breadcrumbs/BreadcrumbItem.js +47 -34
  22. package/dist/Button/Button.d.ts.map +1 -1
  23. package/dist/Button/Button.js +7 -7
  24. package/dist/Calendar/Calendar.d.ts.map +1 -1
  25. package/dist/Calendar/Calendar.js +21 -12
  26. package/dist/Calendar/hooks/useCalendarNavigation.d.ts.map +1 -1
  27. package/dist/Calendar/hooks/useCalendarNavigation.js +8 -4
  28. package/dist/Calendar/styles.d.ts +2 -2
  29. package/dist/Calendar/styles.d.ts.map +1 -1
  30. package/dist/Calendar/styles.js +5 -5
  31. package/dist/Center/Center.d.ts +23 -1
  32. package/dist/Center/Center.d.ts.map +1 -1
  33. package/dist/Center/Center.js +12 -6
  34. package/dist/Chat/ChatComposer.d.ts.map +1 -1
  35. package/dist/Chat/ChatComposer.js +3 -3
  36. package/dist/Chat/ChatComposerDrawer.d.ts.map +1 -1
  37. package/dist/Chat/ChatComposerDrawer.js +2 -2
  38. package/dist/Chat/ChatToolCalls.d.ts.map +1 -1
  39. package/dist/Chat/ChatToolCalls.js +2 -2
  40. package/dist/Chat/useChatDictation.d.ts +1 -1
  41. package/dist/Chat/useChatDictation.d.ts.map +1 -1
  42. package/dist/Chat/useSpeechRecognition.d.ts +1 -1
  43. package/dist/Chat/useSpeechRecognition.d.ts.map +1 -1
  44. package/dist/Chat/useSpeechRecognition.js +4 -3
  45. package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
  46. package/dist/Chat/useTriggerMenu.js +2 -2
  47. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  48. package/dist/CheckboxInput/CheckboxInput.js +2 -2
  49. package/dist/Citation/Citation.d.ts.map +1 -1
  50. package/dist/Citation/Citation.js +5 -5
  51. package/dist/ClickableCard/ClickableCard.d.ts.map +1 -1
  52. package/dist/ClickableCard/ClickableCard.js +4 -4
  53. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  54. package/dist/CodeBlock/CodeBlock.js +1 -1
  55. package/dist/Collapsible/Collapsible.d.ts.map +1 -1
  56. package/dist/Collapsible/Collapsible.js +2 -2
  57. package/dist/CommandPalette/CommandPaletteItem.d.ts.map +1 -1
  58. package/dist/CommandPalette/CommandPaletteItem.js +3 -3
  59. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  60. package/dist/ComplexSelector/ComplexSelector.js +6 -6
  61. package/dist/DateInput/DateInput.d.ts +31 -2
  62. package/dist/DateInput/DateInput.d.ts.map +1 -1
  63. package/dist/DateInput/DateInput.js +90 -22
  64. package/dist/DateInput/MonthScroller.d.ts +35 -0
  65. package/dist/DateInput/MonthScroller.d.ts.map +1 -0
  66. package/dist/DateInput/MonthScroller.js +658 -0
  67. package/dist/DateInput/MonthYearWheels.d.ts +24 -0
  68. package/dist/DateInput/MonthYearWheels.d.ts.map +1 -0
  69. package/dist/DateInput/MonthYearWheels.js +114 -0
  70. package/dist/DateInput/TouchDateField.d.ts +10 -0
  71. package/dist/DateInput/TouchDateField.d.ts.map +1 -0
  72. package/dist/DateInput/TouchDateField.js +810 -0
  73. package/dist/DateInput/Wheel.d.ts +41 -0
  74. package/dist/DateInput/Wheel.d.ts.map +1 -0
  75. package/dist/DateInput/Wheel.js +315 -0
  76. package/dist/DateInput/monthGeometry.d.ts +65 -0
  77. package/dist/DateInput/monthGeometry.d.ts.map +1 -0
  78. package/dist/DateInput/monthGeometry.js +98 -0
  79. package/dist/DateInput/tokens.stylex.d.ts +63 -0
  80. package/dist/DateInput/tokens.stylex.d.ts.map +1 -0
  81. package/dist/DateInput/tokens.stylex.js +74 -0
  82. package/dist/DateInput/useOwnScrollGesture.d.ts +41 -0
  83. package/dist/DateInput/useOwnScrollGesture.d.ts.map +1 -0
  84. package/dist/DateInput/useOwnScrollGesture.js +202 -0
  85. package/dist/DateInput/usePointerDragScroll.d.ts +15 -0
  86. package/dist/DateInput/usePointerDragScroll.d.ts.map +1 -0
  87. package/dist/DateInput/usePointerDragScroll.js +216 -0
  88. package/dist/DateInput/useScrollSettle.d.ts +26 -0
  89. package/dist/DateInput/useScrollSettle.d.ts.map +1 -0
  90. package/dist/DateInput/useScrollSettle.js +149 -0
  91. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  92. package/dist/DateRangeInput/DateRangeInput.js +17 -14
  93. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  94. package/dist/DateTimeInput/DateTimeInput.js +17 -14
  95. package/dist/Dialog/Dialog.d.ts.map +1 -1
  96. package/dist/Dialog/Dialog.js +3 -3
  97. package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts.map +1 -1
  98. package/dist/DropdownMenu/DropdownMenuCheckboxItem.js +2 -2
  99. package/dist/DropdownMenu/DropdownMenuItem.d.ts.map +1 -1
  100. package/dist/DropdownMenu/DropdownMenuItem.js +2 -2
  101. package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts.map +1 -1
  102. package/dist/DropdownMenu/DropdownMenuRadioItem.js +2 -2
  103. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  104. package/dist/DropdownMenu/DropdownMenuSubMenu.js +2 -2
  105. package/dist/Field/FieldLabel.d.ts.map +1 -1
  106. package/dist/Field/FieldLabel.js +4 -4
  107. package/dist/Field/InputClearButton.d.ts.map +1 -1
  108. package/dist/Field/InputClearButton.js +6 -0
  109. package/dist/Field/inputStyles.stylex.d.ts +1 -1
  110. package/dist/Field/inputStyles.stylex.d.ts.map +1 -1
  111. package/dist/Field/inputStyles.stylex.js +5 -5
  112. package/dist/FileInput/FileInput.d.ts.map +1 -1
  113. package/dist/FileInput/FileInput.js +6 -6
  114. package/dist/HoverCard/HoverCard.d.ts +13 -3
  115. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  116. package/dist/HoverCard/HoverCard.js +2 -0
  117. package/dist/HoverCard/index.d.ts +1 -1
  118. package/dist/HoverCard/index.d.ts.map +1 -1
  119. package/dist/HoverCard/useHoverCard.d.ts +17 -2
  120. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  121. package/dist/HoverCard/useHoverCard.js +78 -9
  122. package/dist/InputGroup/InputGroup.js +1 -1
  123. package/dist/Item/Item.d.ts +9 -1
  124. package/dist/Item/Item.d.ts.map +1 -1
  125. package/dist/Item/Item.js +52 -14
  126. package/dist/Layer/index.d.ts +4 -2
  127. package/dist/Layer/index.d.ts.map +1 -1
  128. package/dist/Layer/index.js +2 -0
  129. package/dist/Layer/useLayer.d.ts.map +1 -1
  130. package/dist/Layer/useLayer.js +3 -2
  131. package/dist/Layer/useTouchTrigger.d.ts +113 -0
  132. package/dist/Layer/useTouchTrigger.d.ts.map +1 -0
  133. package/dist/Layer/useTouchTrigger.js +241 -0
  134. package/dist/Layout/container.stylex.d.ts +4 -2
  135. package/dist/Layout/container.stylex.d.ts.map +1 -1
  136. package/dist/Layout/container.stylex.js +19 -11
  137. package/dist/Layout/index.d.ts +1 -0
  138. package/dist/Layout/index.d.ts.map +1 -1
  139. package/dist/Layout/index.js +4 -0
  140. package/dist/Layout/padding.stylex.d.ts +315 -12
  141. package/dist/Layout/padding.stylex.d.ts.map +1 -1
  142. package/dist/Layout/padding.stylex.js +389 -12
  143. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  144. package/dist/Lightbox/Lightbox.js +5 -4
  145. package/dist/Link/Link.d.ts.map +1 -1
  146. package/dist/Link/Link.js +6 -6
  147. package/dist/MetadataList/MetadataList.d.ts.map +1 -1
  148. package/dist/MetadataList/MetadataList.js +1 -1
  149. package/dist/MobileNav/MobileNav.d.ts +1 -0
  150. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  151. package/dist/MobileNav/MobileNav.js +24 -6
  152. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  153. package/dist/MultiSelector/MultiSelector.js +8 -8
  154. package/dist/NavItem/navItemStyles.stylex.d.ts +4 -4
  155. package/dist/NavItem/navItemStyles.stylex.d.ts.map +1 -1
  156. package/dist/NavItem/navItemStyles.stylex.js +4 -4
  157. package/dist/NavMenu/NavHeadingMenuItem.d.ts.map +1 -1
  158. package/dist/NavMenu/NavHeadingMenuItem.js +3 -3
  159. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  160. package/dist/NumberInput/NumberInput.js +36 -8
  161. package/dist/Outline/Outline.d.ts.map +1 -1
  162. package/dist/Outline/Outline.js +3 -3
  163. package/dist/Pagination/Pagination.d.ts.map +1 -1
  164. package/dist/Pagination/Pagination.js +2 -2
  165. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  166. package/dist/PowerSearch/PowerSearch.js +11 -12
  167. package/dist/PowerSearch/PowerSearchToken.d.ts.map +1 -1
  168. package/dist/PowerSearch/PowerSearchToken.js +3 -1
  169. package/dist/PowerSearch/formatFilterValue.d.ts +11 -2
  170. package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
  171. package/dist/PowerSearch/formatFilterValue.js +23 -8
  172. package/dist/RadioList/RadioListItem.d.ts +2 -2
  173. package/dist/RadioList/RadioListItem.d.ts.map +1 -1
  174. package/dist/RadioList/RadioListItem.js +70 -40
  175. package/dist/Resizable/ResizeHandle.d.ts.map +1 -1
  176. package/dist/Resizable/ResizeHandle.js +7 -7
  177. package/dist/Section/Section.d.ts +36 -1
  178. package/dist/Section/Section.d.ts.map +1 -1
  179. package/dist/Section/Section.js +12 -2
  180. package/dist/SegmentedControl/SegmentedControlItem.d.ts.map +1 -1
  181. package/dist/SegmentedControl/SegmentedControlItem.js +7 -2
  182. package/dist/SelectableCard/SelectableCard.d.ts.map +1 -1
  183. package/dist/SelectableCard/SelectableCard.js +3 -3
  184. package/dist/Selector/Selector.d.ts +26 -1
  185. package/dist/Selector/Selector.d.ts.map +1 -1
  186. package/dist/Selector/Selector.js +93 -20
  187. package/dist/Selector/SelectorOption.d.ts +14 -1
  188. package/dist/Selector/SelectorOption.d.ts.map +1 -1
  189. package/dist/Selector/SelectorOption.js +6 -0
  190. package/dist/Selector/SelectorRowLayoutContext.d.ts +8 -0
  191. package/dist/Selector/SelectorRowLayoutContext.d.ts.map +1 -0
  192. package/dist/Selector/SelectorRowLayoutContext.js +27 -0
  193. package/dist/Selector/types.d.ts +1 -0
  194. package/dist/Selector/types.d.ts.map +1 -1
  195. package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
  196. package/dist/SideNav/SideNavHeading.js +4 -4
  197. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  198. package/dist/SideNav/SideNavItem.js +3 -3
  199. package/dist/Slider/Slider.d.ts.map +1 -1
  200. package/dist/Slider/Slider.js +7 -7
  201. package/dist/Stack/Stack.d.ts +23 -1
  202. package/dist/Stack/Stack.d.ts.map +1 -1
  203. package/dist/Stack/Stack.js +12 -6
  204. package/dist/Switch/Switch.d.ts.map +1 -1
  205. package/dist/Switch/Switch.js +2 -2
  206. package/dist/TabList/Tab.d.ts.map +1 -1
  207. package/dist/TabList/Tab.js +1 -1
  208. package/dist/TabList/TabMenu.d.ts.map +1 -1
  209. package/dist/TabList/TabMenu.js +3 -3
  210. package/dist/Table/TableRow.js +4 -4
  211. package/dist/Table/plugins/columnResize/useTableColumnResize.d.ts.map +1 -1
  212. package/dist/Table/plugins/columnResize/useTableColumnResize.js +1 -1
  213. package/dist/Table/plugins/filtering/useTableFiltering.d.ts.map +1 -1
  214. package/dist/Table/plugins/filtering/useTableFiltering.js +2 -2
  215. package/dist/Table/plugins/groupedRows/useTableGroupedRows.d.ts.map +1 -1
  216. package/dist/Table/plugins/groupedRows/useTableGroupedRows.js +2 -2
  217. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  218. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +2 -2
  219. package/dist/Table/plugins/sortable/useTableSortable.d.ts.map +1 -1
  220. package/dist/Table/plugins/sortable/useTableSortable.js +1 -1
  221. package/dist/Table/plugins/sortable/useTableSortableState.d.ts +5 -3
  222. package/dist/Table/plugins/sortable/useTableSortableState.d.ts.map +1 -1
  223. package/dist/Table/plugins/sortable/useTableSortableState.js +12 -9
  224. package/dist/Table/plugins/tree/useTableTreeData.d.ts.map +1 -1
  225. package/dist/Table/plugins/tree/useTableTreeData.js +3 -3
  226. package/dist/TextArea/TextArea.js +1 -1
  227. package/dist/TextInput/TextInput.js +1 -1
  228. package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
  229. package/dist/Thumbnail/Thumbnail.js +3 -3
  230. package/dist/TimeInput/TimeInput.js +2 -2
  231. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  232. package/dist/Timestamp/Timestamp.js +6 -4
  233. package/dist/Timestamp/formatInstant.d.ts +3 -2
  234. package/dist/Timestamp/formatInstant.d.ts.map +1 -1
  235. package/dist/Timestamp/formatInstant.js +8 -8
  236. package/dist/Timestamp/tooltipEntries.d.ts +2 -1
  237. package/dist/Timestamp/tooltipEntries.d.ts.map +1 -1
  238. package/dist/Timestamp/tooltipEntries.js +5 -3
  239. package/dist/Toast/Toast.d.ts +4 -3
  240. package/dist/Toast/Toast.d.ts.map +1 -1
  241. package/dist/Toast/Toast.js +10 -7
  242. package/dist/Token/Token.d.ts.map +1 -1
  243. package/dist/Token/Token.js +6 -6
  244. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  245. package/dist/Tokenizer/Tokenizer.js +1 -1
  246. package/dist/Tooltip/Tooltip.d.ts +16 -3
  247. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  248. package/dist/Tooltip/Tooltip.js +2 -0
  249. package/dist/Tooltip/index.d.ts +1 -1
  250. package/dist/Tooltip/index.d.ts.map +1 -1
  251. package/dist/Tooltip/useTooltip.d.ts +21 -2
  252. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  253. package/dist/Tooltip/useTooltip.js +71 -13
  254. package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
  255. package/dist/TopNav/TopNavHeading.js +4 -4
  256. package/dist/TopNav/TopNavItem.d.ts.map +1 -1
  257. package/dist/TopNav/TopNavItem.js +3 -3
  258. package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
  259. package/dist/TopNav/TopNavMegaMenu.js +2 -2
  260. package/dist/TopNav/TopNavMegaMenuItem.d.ts.map +1 -1
  261. package/dist/TopNav/TopNavMegaMenuItem.js +2 -2
  262. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  263. package/dist/TopNav/TopNavMenu.js +4 -4
  264. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  265. package/dist/TreeList/TreeListItem.js +7 -7
  266. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  267. package/dist/Typeahead/BaseTypeahead.js +2 -2
  268. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  269. package/dist/Typeahead/Typeahead.js +1 -1
  270. package/dist/astryx.css +124 -62
  271. package/dist/hooks/containerReveal.stylex.d.ts.map +1 -1
  272. package/dist/hooks/containerReveal.stylex.js +18 -18
  273. package/dist/hooks/scrollbarGutter.d.ts +28 -0
  274. package/dist/hooks/scrollbarGutter.d.ts.map +1 -0
  275. package/dist/hooks/scrollbarGutter.js +103 -0
  276. package/dist/hooks/useAutoMediaMode.d.ts +31 -0
  277. package/dist/hooks/useAutoMediaMode.d.ts.map +1 -0
  278. package/dist/hooks/useAutoMediaMode.js +201 -0
  279. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  280. package/dist/hooks/useInputStatusIcon.js +1 -1
  281. package/dist/hooks/useListFocus.d.ts +5 -2
  282. package/dist/hooks/useListFocus.d.ts.map +1 -1
  283. package/dist/hooks/useListFocus.js +12 -6
  284. package/dist/hooks/useScrollLock.d.ts +4 -0
  285. package/dist/hooks/useScrollLock.d.ts.map +1 -1
  286. package/dist/hooks/useScrollLock.js +13 -1
  287. package/dist/i18n/InternationalizationContext.d.ts.map +1 -1
  288. package/dist/i18n/InternationalizationContext.js +2 -0
  289. package/dist/i18n/index.d.ts +6 -0
  290. package/dist/i18n/index.d.ts.map +1 -1
  291. package/dist/i18n/index.js +6 -0
  292. package/dist/i18n/useCollator.d.ts +12 -0
  293. package/dist/i18n/useCollator.d.ts.map +1 -0
  294. package/dist/i18n/useCollator.js +40 -0
  295. package/dist/i18n/useLocale.d.ts +3 -0
  296. package/dist/i18n/useLocale.d.ts.map +1 -0
  297. package/dist/i18n/useLocale.js +31 -0
  298. package/dist/theme/MediaTheme.d.ts +25 -5
  299. package/dist/theme/MediaTheme.d.ts.map +1 -1
  300. package/dist/theme/MediaTheme.js +31 -6
  301. package/dist/theme/defineTheme.d.ts +5 -0
  302. package/dist/theme/defineTheme.d.ts.map +1 -1
  303. package/dist/theme/defineTheme.js +5 -0
  304. package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
  305. package/dist/theme/derivedVarRegistry.js +7 -0
  306. package/dist/theme/generateThemeRules.d.ts.map +1 -1
  307. package/dist/theme/generateThemeRules.js +66 -5
  308. package/dist/theme/index.d.ts +1 -1
  309. package/dist/theme/index.d.ts.map +1 -1
  310. package/dist/theme/syntax/index.d.ts +6 -0
  311. package/dist/theme/syntax/index.d.ts.map +1 -1
  312. package/dist/theme/syntax/index.js +7 -2
  313. package/dist/utils/dateParser.d.ts +4 -3
  314. package/dist/utils/dateParser.d.ts.map +1 -1
  315. package/dist/utils/dateParser.js +8 -8
  316. package/dist/utils/index.d.ts +1 -1
  317. package/dist/utils/index.d.ts.map +1 -1
  318. package/dist/utils/index.js +1 -1
  319. package/dist/utils/plainDate.d.ts +5 -2
  320. package/dist/utils/plainDate.d.ts.map +1 -1
  321. package/dist/utils/plainDate.js +20 -4
  322. package/locales/en.json +28 -0
  323. package/locales/pseudo.json +21 -0
  324. package/package.json +3 -3
  325. package/src/Avatar/Avatar.doc.mjs +10 -6
  326. package/src/Avatar/Avatar.test.tsx +219 -1
  327. package/src/Avatar/Avatar.tsx +179 -37
  328. package/src/Avatar/AvatarStatusDot.tsx +30 -4
  329. package/src/Avatar/AvatarStatusLabelContext.ts +47 -0
  330. package/src/AvatarGroup/AvatarGroup.doc.mjs +10 -6
  331. package/src/AvatarGroup/AvatarGroup.test.tsx +62 -0
  332. package/src/AvatarGroup/AvatarGroup.tsx +3 -1
  333. package/src/AvatarGroup/AvatarGroupOverflow.tsx +31 -8
  334. package/src/Banner/Banner.doc.mjs +2 -0
  335. package/src/Blockquote/Blockquote.doc.mjs +30 -7
  336. package/src/Blockquote/Blockquote.test.tsx +34 -3
  337. package/src/Blockquote/Blockquote.tsx +11 -9
  338. package/src/BottomSheet/BottomSheet.doc.mjs +3 -3
  339. package/src/BottomSheet/BottomSheet.test.tsx +24 -0
  340. package/src/BottomSheet/BottomSheetPanel.test.tsx +91 -0
  341. package/src/BottomSheet/BottomSheetPanel.tsx +58 -3
  342. package/src/Breadcrumbs/BreadcrumbItem.tsx +36 -23
  343. package/src/Breadcrumbs/Breadcrumbs.doc.mjs +26 -2
  344. package/src/Breadcrumbs/Breadcrumbs.test.tsx +31 -0
  345. package/src/Button/Button.tsx +12 -9
  346. package/src/Calendar/Calendar.tsx +15 -9
  347. package/src/Calendar/hooks/useCalendarNavigation.ts +6 -4
  348. package/src/Calendar/styles.ts +8 -5
  349. package/src/Center/Center.doc.mjs +48 -0
  350. package/src/Center/Center.test.tsx +158 -0
  351. package/src/Center/Center.tsx +50 -9
  352. package/src/Chat/ChatComposer.tsx +11 -5
  353. package/src/Chat/ChatComposerDrawer.tsx +4 -1
  354. package/src/Chat/ChatToolCalls.tsx +13 -4
  355. package/src/Chat/{useChatDictation.test.ts → useChatDictation.test.tsx} +29 -14
  356. package/src/Chat/useChatDictation.ts +1 -1
  357. package/src/Chat/useSpeechRecognition.ts +5 -3
  358. package/src/Chat/useTriggerMenu.tsx +4 -1
  359. package/src/CheckboxInput/CheckboxInput.tsx +5 -2
  360. package/src/Citation/Citation.test.tsx +6 -1
  361. package/src/Citation/Citation.tsx +11 -5
  362. package/src/ClickableCard/ClickableCard.tsx +7 -4
  363. package/src/CodeBlock/CodeBlock.tsx +4 -1
  364. package/src/Collapsible/Collapsible.tsx +5 -2
  365. package/src/CommandPalette/CommandPaletteItem.tsx +6 -3
  366. package/src/ComplexSelector/ComplexSelector.doc.mjs +10 -0
  367. package/src/ComplexSelector/ComplexSelector.tsx +14 -7
  368. package/src/DateInput/DateInput.test.tsx +17 -1
  369. package/src/DateInput/DateInput.tsx +93 -21
  370. package/src/DateInput/DateInputTouch.test.tsx +2582 -0
  371. package/src/DateInput/MonthScroller.tsx +878 -0
  372. package/src/DateInput/MonthYearWheels.tsx +147 -0
  373. package/src/DateInput/TouchDateField.tsx +1223 -0
  374. package/src/DateInput/Wheel.tsx +473 -0
  375. package/src/DateInput/monthGeometry.ts +118 -0
  376. package/src/DateInput/tokens.stylex.ts +99 -0
  377. package/src/DateInput/useOwnScrollGesture.ts +227 -0
  378. package/src/DateInput/usePointerDragScroll.ts +220 -0
  379. package/src/DateInput/useScrollSettle.ts +164 -0
  380. package/src/DateRangeInput/DateRangeInput.doc.mjs +1 -1
  381. package/src/DateRangeInput/DateRangeInput.tsx +31 -13
  382. package/src/DateTimeInput/DateTimeInput.tsx +25 -9
  383. package/src/Dialog/Dialog.test.tsx +24 -0
  384. package/src/Dialog/Dialog.tsx +3 -0
  385. package/src/DropdownMenu/DropdownMenuCheckboxItem.tsx +5 -2
  386. package/src/DropdownMenu/DropdownMenuItem.tsx +5 -2
  387. package/src/DropdownMenu/DropdownMenuRadioItem.tsx +5 -2
  388. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +5 -2
  389. package/src/Field/Field.doc.mjs +2 -0
  390. package/src/Field/FieldLabel.tsx +9 -3
  391. package/src/Field/InputClearButton.test.tsx +110 -3
  392. package/src/Field/InputClearButton.tsx +38 -0
  393. package/src/Field/inputStyles.stylex.ts +17 -13
  394. package/src/FileInput/FileInput.tsx +15 -8
  395. package/src/FormLayout/FormLayout.doc.mjs +1 -1
  396. package/src/HoverCard/HoverCard.doc.mjs +18 -0
  397. package/src/HoverCard/HoverCard.test.tsx +189 -0
  398. package/src/HoverCard/HoverCard.tsx +22 -2
  399. package/src/HoverCard/index.ts +1 -0
  400. package/src/HoverCard/useHoverCard.doc.mjs +2 -0
  401. package/src/HoverCard/useHoverCard.tsx +103 -7
  402. package/src/InputGroup/InputGroup.tsx +1 -1
  403. package/src/Item/Item.doc.mjs +1 -0
  404. package/src/Item/Item.test.tsx +50 -0
  405. package/src/Item/Item.tsx +54 -6
  406. package/src/Layer/index.ts +10 -0
  407. package/src/Layer/useLayer.tsx +13 -2
  408. package/src/Layer/useTouchTrigger.test.ts +70 -0
  409. package/src/Layer/useTouchTrigger.ts +366 -0
  410. package/src/Layout/Layout.doc.mjs +18 -0
  411. package/src/Layout/container.stylex.ts +11 -3
  412. package/src/Layout/index.ts +4 -0
  413. package/src/Layout/overlayPaddingReset.test.tsx +222 -0
  414. package/src/Layout/padding.stylex.ts +192 -12
  415. package/src/Lightbox/Lightbox.tsx +11 -3
  416. package/src/Link/Link.tsx +9 -6
  417. package/src/MetadataList/MetadataList.tsx +4 -1
  418. package/src/MobileNav/MobileNav.tsx +61 -5
  419. package/src/MobileNav/MobileNavEntryAnimation.test.tsx +212 -0
  420. package/src/MobileNav/MobileNavScrollbarGutter.test.tsx +109 -0
  421. package/src/MultiSelector/MultiSelector.test.tsx +1 -1
  422. package/src/MultiSelector/MultiSelector.tsx +26 -10
  423. package/src/NavItem/navItemStyles.stylex.ts +7 -4
  424. package/src/NavMenu/NavHeadingMenuItem.tsx +6 -3
  425. package/src/NumberInput/NumberInput.doc.mjs +24 -0
  426. package/src/NumberInput/NumberInput.test.tsx +138 -0
  427. package/src/NumberInput/NumberInput.tsx +47 -8
  428. package/src/Outline/Outline.tsx +5 -2
  429. package/src/Pagination/Pagination.tsx +5 -2
  430. package/src/Popover/Popover.test.tsx +27 -1
  431. package/src/PowerSearch/PowerSearch.tsx +17 -8
  432. package/src/PowerSearch/PowerSearchToken.tsx +3 -0
  433. package/src/PowerSearch/formatFilterValue.test.ts +22 -2
  434. package/src/PowerSearch/formatFilterValue.ts +31 -6
  435. package/src/RadioList/RadioList.doc.mjs +1 -1
  436. package/src/RadioList/RadioList.test.tsx +170 -2
  437. package/src/RadioList/RadioListItem.tsx +88 -53
  438. package/src/Resizable/ResizeHandle.test.tsx +22 -0
  439. package/src/Resizable/ResizeHandle.tsx +25 -7
  440. package/src/Section/Section.doc.mjs +55 -0
  441. package/src/Section/Section.test.tsx +201 -4
  442. package/src/Section/Section.tsx +69 -0
  443. package/src/SegmentedControl/SegmentedControl.test.tsx +31 -0
  444. package/src/SegmentedControl/SegmentedControlItem.tsx +15 -3
  445. package/src/SelectableCard/SelectableCard.tsx +6 -3
  446. package/src/Selector/Selector.doc.mjs +16 -2
  447. package/src/Selector/Selector.test.tsx +525 -1
  448. package/src/Selector/Selector.tsx +180 -25
  449. package/src/Selector/SelectorOption.doc.mjs +7 -0
  450. package/src/Selector/SelectorOption.tsx +21 -0
  451. package/src/Selector/SelectorRowLayoutContext.ts +32 -0
  452. package/src/Selector/types.ts +4 -4
  453. package/src/SideNav/SideNavHeading.tsx +14 -5
  454. package/src/SideNav/SideNavItem.tsx +9 -3
  455. package/src/Slider/Slider.tsx +25 -6
  456. package/src/Stack/Stack.doc.mjs +72 -0
  457. package/src/Stack/Stack.test.tsx +156 -0
  458. package/src/Stack/Stack.tsx +50 -8
  459. package/src/Switch/Switch.tsx +5 -2
  460. package/src/TabList/Tab.tsx +4 -1
  461. package/src/TabList/TabMenu.tsx +9 -3
  462. package/src/Table/TableRow.tsx +4 -4
  463. package/src/Table/plugins/columnResize/useTableColumnResize.tsx +6 -2
  464. package/src/Table/plugins/filtering/useTableFiltering.tsx +4 -1
  465. package/src/Table/plugins/groupedRows/useTableGroupedRows.tsx +10 -3
  466. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +6 -3
  467. package/src/Table/plugins/sortable/useTableSortable.tsx +4 -1
  468. package/src/Table/plugins/sortable/useTableSortableState.test.tsx +34 -0
  469. package/src/Table/plugins/sortable/useTableSortableState.tsx +26 -23
  470. package/src/Table/plugins/tree/useTableTreeData.tsx +10 -4
  471. package/src/TextArea/TextArea.doc.mjs +1 -1
  472. package/src/TextArea/TextArea.tsx +1 -1
  473. package/src/TextInput/TextInput.tsx +1 -1
  474. package/src/Thumbnail/Thumbnail.tsx +9 -3
  475. package/src/TimeInput/TimeInput.tsx +1 -1
  476. package/src/Timestamp/Timestamp.test.tsx +27 -7
  477. package/src/Timestamp/Timestamp.tsx +8 -4
  478. package/src/Timestamp/formatInstant.ts +9 -7
  479. package/src/Timestamp/tooltipEntries.test.ts +11 -6
  480. package/src/Timestamp/tooltipEntries.ts +6 -2
  481. package/src/Toast/Toast.tsx +9 -7
  482. package/src/Token/Token.tsx +14 -5
  483. package/src/Tokenizer/Tokenizer.tsx +4 -1
  484. package/src/Tooltip/Tooltip.doc.mjs +18 -0
  485. package/src/Tooltip/Tooltip.test.tsx +254 -1
  486. package/src/Tooltip/Tooltip.tsx +22 -2
  487. package/src/Tooltip/index.ts +1 -0
  488. package/src/Tooltip/useTooltip.doc.mjs +2 -0
  489. package/src/Tooltip/useTooltip.tsx +99 -21
  490. package/src/TopNav/TopNavHeading.tsx +13 -4
  491. package/src/TopNav/TopNavItem.tsx +6 -3
  492. package/src/TopNav/TopNavMegaMenu.tsx +5 -2
  493. package/src/TopNav/TopNavMegaMenuItem.tsx +5 -2
  494. package/src/TopNav/TopNavMenu.tsx +10 -4
  495. package/src/TreeList/TreeListItem.tsx +22 -7
  496. package/src/Typeahead/BaseTypeahead.tsx +5 -2
  497. package/src/Typeahead/Typeahead.tsx +4 -1
  498. package/src/hooks/containerReveal.stylex.ts +54 -18
  499. package/src/hooks/scrollbarGutter.test.ts +167 -0
  500. package/src/hooks/scrollbarGutter.ts +121 -0
  501. package/src/hooks/useAutoMediaMode.test.ts +109 -0
  502. package/src/hooks/useAutoMediaMode.ts +233 -0
  503. package/src/hooks/useInputStatusIcon.tsx +4 -1
  504. package/src/hooks/useListFocus.doc.mjs +2 -2
  505. package/src/hooks/useListFocus.test.tsx +65 -3
  506. package/src/hooks/useListFocus.ts +15 -7
  507. package/src/hooks/useScrollLock.doc.mjs +4 -4
  508. package/src/hooks/useScrollLock.test.ts +73 -0
  509. package/src/hooks/useScrollLock.ts +14 -0
  510. package/src/i18n/InternationalizationContext.ts +2 -0
  511. package/src/i18n/__tests__/e2e-powersearch.test.tsx +37 -1
  512. package/src/i18n/__tests__/useCollator.test.tsx +115 -0
  513. package/src/i18n/__tests__/useLocale.test.tsx +52 -0
  514. package/src/i18n/index.ts +6 -0
  515. package/src/i18n/useCollator.doc.mjs +84 -0
  516. package/src/i18n/useCollator.ts +44 -0
  517. package/src/i18n/useLocale.doc.mjs +70 -0
  518. package/src/i18n/useLocale.ts +34 -0
  519. package/src/i18n/useTranslator.doc.mjs +1 -1
  520. package/src/reset.css +9 -2
  521. package/src/reset.test.ts +22 -0
  522. package/src/theme/MediaTheme.doc.mjs +23 -4
  523. package/src/theme/MediaTheme.dom.test.tsx +97 -0
  524. package/src/theme/MediaTheme.tsx +42 -7
  525. package/src/theme/defineTheme.test.ts +9 -3
  526. package/src/theme/defineTheme.ts +5 -0
  527. package/src/theme/derivedVarRegistry.test.ts +19 -5
  528. package/src/theme/derivedVarRegistry.ts +4 -0
  529. package/src/theme/generateThemeRules.test.ts +118 -0
  530. package/src/theme/generateThemeRules.ts +83 -5
  531. package/src/theme/index.ts +1 -1
  532. package/src/theme/syntax/index.ts +6 -2
  533. package/src/theme/syntax/serverSafeSyntax.test.ts +68 -0
  534. package/src/utils/dateParser.test.ts +20 -0
  535. package/src/utils/dateParser.ts +14 -7
  536. package/src/utils/index.ts +2 -0
  537. package/src/utils/plainDate.test.ts +6 -0
  538. package/src/utils/plainDate.ts +21 -4
@@ -0,0 +1,2582 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file DateInputTouch.test.tsx
5
+ * @input Uses vitest, @testing-library/react, DateInput and its helpers
6
+ * @output Behavior coverage for the responsive date picker
7
+ * @position Test file for /packages/core/src/DateInput/
8
+ *
9
+ * What jsdom can and cannot see here matters, and the split is deliberate:
10
+ *
11
+ * - The month math is pure, so it is tested directly and exhaustively.
12
+ * - Which surface is chosen, and the field contract each one honors, are
13
+ * tested through the DOM with `matchMedia` stubbed per test.
14
+ * - Snapping, momentum and the scroll-driven falloff are CSS the browser
15
+ * resolves and jsdom does not implement at all. Those are asserted on the
16
+ * style DEFINITION instead (the last describe block), which at least fails
17
+ * loudly if someone deletes the property; the real verification is a
18
+ * browser, and the values it produced are recorded in the doc file.
19
+ */
20
+
21
+ import {
22
+ describe,
23
+ it,
24
+ expect,
25
+ vi,
26
+ beforeAll,
27
+ afterAll,
28
+ beforeEach,
29
+ afterEach,
30
+ } from 'vitest';
31
+ import {
32
+ render,
33
+ screen,
34
+ fireEvent,
35
+ within,
36
+ waitFor,
37
+ } from '@testing-library/react';
38
+ import {readFileSync} from 'node:fs';
39
+ import path from 'node:path';
40
+ import {fileURLToPath} from 'node:url';
41
+ import {useState} from 'react';
42
+ import type {ISODateString} from '../utils';
43
+ import {InputGroup} from '../InputGroup';
44
+ import {stableClassName} from '../naming';
45
+ import {DateInput} from './DateInput';
46
+ import {
47
+ toMonthIndex,
48
+ monthIndexOf,
49
+ fromMonthIndex,
50
+ clampIndex,
51
+ rowAtScrollOffset,
52
+ scrollOffsetForRow,
53
+ paneWindow,
54
+ rowsIn,
55
+ DEFAULT_MONTH_REACH,
56
+ } from './monthGeometry';
57
+ import {SWIPE_DISTANCE} from './useOwnScrollGesture';
58
+ import {DRAG_SLOP} from './usePointerDragScroll';
59
+ import {SCROLL_QUIET_MS} from './useScrollSettle';
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // jsdom scaffolding
63
+ // ---------------------------------------------------------------------------
64
+
65
+ class MockResizeObserver {
66
+ observe() {}
67
+ unobserve() {}
68
+ disconnect() {}
69
+ }
70
+
71
+ /** An arbitrary pane size for the pure geometry tests. */
72
+ const PANE = 264;
73
+ /**
74
+ * What the month scrollport measures as. The scroller pages along the INLINE
75
+ * axis, so its pane size is a WIDTH — a phone's worth, since the value only
76
+ * has to be non-zero and consistent for the virtualization to mount panes.
77
+ */
78
+ const SCROLLPORT_WIDTH = 360;
79
+
80
+ /** Matches the repo-wide setup polyfill, so hover-gated behavior still works. */
81
+ const HOVER_CAPABLE = /\(\s*hover\s*:\s*hover\s*\)/;
82
+
83
+ /**
84
+ * Answer media queries the way a given device would.
85
+ *
86
+ * Width queries are answered HONESTLY against `width`, so a width bound
87
+ * creeping back into the surface switch fails a test rather than passing
88
+ * silently on a stub that ignores it.
89
+ */
90
+ function stubMedia({
91
+ pointer,
92
+ anyPointer,
93
+ width,
94
+ }: {
95
+ pointer: 'coarse' | 'fine';
96
+ anyPointer?: 'coarse' | 'fine';
97
+ width: number;
98
+ }): void {
99
+ vi.stubGlobal('matchMedia', (query: string) => {
100
+ let matches: boolean;
101
+ const maxWidth = /\(\s*max-width:\s*(\d+)px\s*\)/.exec(query);
102
+ const minWidth = /\(\s*min-width:\s*(\d+)px\s*\)/.exec(query);
103
+ if (/any-pointer:\s*coarse/.test(query)) {
104
+ matches = (anyPointer ?? pointer) === 'coarse';
105
+ } else if (/pointer:\s*coarse/.test(query)) {
106
+ matches = pointer === 'coarse';
107
+ } else if (/pointer:\s*fine/.test(query)) {
108
+ matches = pointer === 'fine';
109
+ } else if (maxWidth) {
110
+ matches = width <= Number(maxWidth[1]);
111
+ } else if (minWidth) {
112
+ matches = width >= Number(minWidth[1]);
113
+ } else {
114
+ matches = HOVER_CAPABLE.test(query);
115
+ }
116
+ // A compound query is only true when every part of it is.
117
+ if (matches && maxWidth && query.includes('pointer:')) {
118
+ matches = width <= Number(maxWidth[1]);
119
+ }
120
+ return {
121
+ matches,
122
+ media: query,
123
+ onchange: null,
124
+ addListener: () => {},
125
+ removeListener: () => {},
126
+ addEventListener: () => {},
127
+ removeEventListener: () => {},
128
+ dispatchEvent: () => false,
129
+ };
130
+ });
131
+ }
132
+
133
+ function setViewport(kind: 'mobile' | 'desktop'): void {
134
+ stubMedia(
135
+ kind === 'mobile'
136
+ ? {pointer: 'coarse', width: 393}
137
+ : {pointer: 'fine', width: 1280},
138
+ );
139
+ }
140
+
141
+ /**
142
+ * Run `fn` with the month scrollport reporting the height its CSS gives it.
143
+ *
144
+ * jsdom lays nothing out, so `clientHeight` is 0 and the scroller mounts no
145
+ * panes at all. The override is installed only around the work that needs it
146
+ * and torn down immediately: a getter left on `HTMLElement.prototype` makes
147
+ * EVERY later DOM read take a slow path, measured at ~2.4s per test here.
148
+ */
149
+ /** One animation frame, for the rAF-throttled scroll handlers. */
150
+ async function frame(): Promise<void> {
151
+ return new Promise(resolve => requestAnimationFrame(() => resolve()));
152
+ }
153
+
154
+ function withLayout<T>(fn: () => T): T {
155
+ // jsdom defines clientWidth on Element.prototype; shadowing it on
156
+ // HTMLElement.prototype and deleting the shadow afterwards restores the
157
+ // original getter without having to copy its descriptor.
158
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
159
+ configurable: true,
160
+ get(this: HTMLElement) {
161
+ return this.dataset?.scroller === 'months' ? SCROLLPORT_WIDTH : 0;
162
+ },
163
+ });
164
+ try {
165
+ return fn();
166
+ } finally {
167
+ delete (HTMLElement.prototype as {clientWidth?: unknown}).clientWidth;
168
+ }
169
+ }
170
+
171
+ beforeAll(() => {
172
+ Element.prototype.scrollTo = vi.fn();
173
+ // jsdom's <dialog> implements none of these.
174
+ HTMLDialogElement.prototype.showModal = vi.fn(function (
175
+ this: HTMLDialogElement,
176
+ ) {
177
+ this.open = true;
178
+ });
179
+ HTMLDialogElement.prototype.show = vi.fn(function (this: HTMLDialogElement) {
180
+ this.open = true;
181
+ });
182
+ HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) {
183
+ this.open = false;
184
+ });
185
+ });
186
+
187
+ afterAll(() => {
188
+ vi.useRealTimers();
189
+ });
190
+
191
+ beforeEach(() => {
192
+ vi.setSystemTime(new Date(2026, 2, 15)); // 15 March 2026, local
193
+ // Re-stubbed per test, not once at module scope: the afterEach below clears
194
+ // every stub, and matchMedia has to be re-pointed for each test anyway.
195
+ vi.stubGlobal('ResizeObserver', MockResizeObserver);
196
+ setViewport('mobile');
197
+ });
198
+
199
+ afterEach(() => {
200
+ vi.unstubAllGlobals();
201
+ });
202
+
203
+ /**
204
+ * Bounded by default. Unbounded, the scroller mounts seven month panes — 294
205
+ * day buttons — for every render, and under jsdom with StyleX's dev runtime
206
+ * that is seconds a test. Three months exercises the same paths; the tests
207
+ * that are actually about the range pass their own.
208
+ */
209
+ const DEFAULT_RANGE = {min: '2026-02-01', max: '2026-04-30'} as const;
210
+
211
+ function Controlled({
212
+ initial,
213
+ onChange,
214
+ ...props
215
+ }: {initial?: ISODateString} & Partial<
216
+ React.ComponentProps<typeof DateInput>
217
+ >) {
218
+ const [value, setValue] = useState<ISODateString | undefined>(initial);
219
+ return (
220
+ <DateInput
221
+ label="Event date"
222
+ {...DEFAULT_RANGE}
223
+ {...props}
224
+ value={value}
225
+ // Composed, not overridden: spreading a test's `onChange` over this one
226
+ // used to replace the state setter, so the field silently stopped
227
+ // updating in exactly the tests that were watching it most closely.
228
+ onChange={next => {
229
+ setValue(next);
230
+ onChange?.(next);
231
+ }}
232
+ />
233
+ );
234
+ }
235
+
236
+ /** The closed field — a real input on both surfaces. */
237
+ const field = (): HTMLInputElement => screen.getByRole('combobox');
238
+
239
+ /** Render, then open the picker. Both mount panes, so both need the layout. */
240
+ function renderAndOpen(
241
+ ui: React.ReactElement = <Controlled initial="2026-03-21" />,
242
+ ): void {
243
+ withLayout(() => {
244
+ render(ui);
245
+ fireEvent.click(field());
246
+ });
247
+ }
248
+
249
+ /**
250
+ * One month's grid. Panes for neighbouring months are mounted too, so every
251
+ * day query has to say which month it means.
252
+ */
253
+ function pane(label: string): HTMLElement {
254
+ const grid = screen
255
+ .getAllByRole('grid')
256
+ .find(g => g.getAttribute('aria-label') === label);
257
+ if (grid == null) {
258
+ throw new Error(`no pane for ${label}`);
259
+ }
260
+ return grid;
261
+ }
262
+
263
+ /** The decorative weekday row: the only 7-cell aria-hidden block. */
264
+ function weekdayRow(): HTMLElement {
265
+ const row = [...document.querySelectorAll('[aria-hidden="true"]')].find(
266
+ el => el.children.length === 7,
267
+ );
268
+ if (row == null) {
269
+ throw new Error('no weekday row');
270
+ }
271
+ return row as HTMLElement;
272
+ }
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // Which surface, and why
276
+ // ---------------------------------------------------------------------------
277
+
278
+ describe('DateInput — surface selection', () => {
279
+ it('switches on the pointer alone, so a tablet gets the picker too', () => {
280
+ // `pointer` is the PRIMARY device, which is what makes it the whole test:
281
+ // a touchscreen laptop reports `fine` (its trackpad) and keeps the
282
+ // typable field, and a narrowed desktop window is still a mouse. A width
283
+ // bound would only re-exclude tablets — the clearest case for a thumb
284
+ // picker there is — so there deliberately is not one.
285
+ //
286
+ // An 1194px tablet in landscape: coarse pointer, far wider than any
287
+ // handset breakpoint. It answers width queries honestly, so a width bound
288
+ // creeping back in would fail here rather than pass silently.
289
+ stubMedia({pointer: 'coarse', width: 1194});
290
+ render(<Controlled initial="2026-03-21" />);
291
+ expect(field()).toHaveAttribute('readonly');
292
+ });
293
+
294
+ it('keeps the typable field for a touchscreen laptop', () => {
295
+ // Touch available, but the trackpad is primary — so `pointer` is `fine`
296
+ // and only `any-pointer` is coarse. The keyboard is right there, and
297
+ // typing a date beats scrolling to it.
298
+ stubMedia({pointer: 'fine', anyPointer: 'coarse', width: 1366});
299
+ render(<Controlled initial="2026-03-21" />);
300
+ expect(field()).not.toHaveAttribute('readonly');
301
+ });
302
+
303
+ it('keeps the typable field in a narrowed desktop window', () => {
304
+ stubMedia({pointer: 'fine', width: 500});
305
+ render(<Controlled initial="2026-03-21" />);
306
+ expect(field()).not.toHaveAttribute('readonly');
307
+ });
308
+
309
+ it('renders the desktop DateInput when the query does not match', () => {
310
+ setViewport('desktop');
311
+ render(<Controlled initial="2026-03-21" />);
312
+ // The desktop control is a text field you can type into...
313
+ expect(field()).not.toHaveAttribute('readonly');
314
+ expect(field()).not.toHaveAttribute('inputmode');
315
+ // ...and it opens a popover, not a sheet.
316
+ expect(document.querySelector('dialog')).toBeNull();
317
+ });
318
+
319
+ it('accepts typed input on the desktop surface', () => {
320
+ setViewport('desktop');
321
+ const onChange = vi.fn();
322
+ render(
323
+ <DateInput label="Event date" onChange={onChange} min={undefined} />,
324
+ );
325
+ fireEvent.change(field(), {target: {value: '2026-03-25'}});
326
+ expect(onChange).toHaveBeenCalledWith('2026-03-25');
327
+ });
328
+
329
+ it('renders the touch field when the query matches', () => {
330
+ render(<Controlled initial="2026-03-21" />);
331
+ const input = field();
332
+ // Still an input — same element, same role, so `ref` stays honest and the
333
+ // label associates natively — but the picker is the only way to change it.
334
+ expect(input.tagName).toBe('INPUT');
335
+ expect(input).toHaveAttribute('readonly');
336
+ // What actually keeps the virtual keyboard from covering the sheet.
337
+ expect(input).toHaveAttribute('inputmode', 'none');
338
+ });
339
+
340
+ it('forwards ref to the input on both surfaces', () => {
341
+ const desktopRef = {current: null as HTMLInputElement | null};
342
+ setViewport('desktop');
343
+ const {unmount} = render(
344
+ <Controlled initial="2026-03-21" ref={desktopRef} />,
345
+ );
346
+ expect(desktopRef.current).toBeInstanceOf(HTMLInputElement);
347
+ unmount();
348
+
349
+ const mobileRef = {current: null as HTMLInputElement | null};
350
+ setViewport('mobile');
351
+ render(<Controlled initial="2026-03-21" ref={mobileRef} />);
352
+ expect(mobileRef.current).toBeInstanceOf(HTMLInputElement);
353
+ });
354
+ });
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // The field contract, honored identically on the touch surface
358
+ // ---------------------------------------------------------------------------
359
+
360
+ describe('DateInput — field parity', () => {
361
+ it('shows a placeholder until a date is chosen, then the formatted value', () => {
362
+ const {rerender} = render(
363
+ <DateInput label="Ship date" onChange={() => {}} />,
364
+ );
365
+ expect(field()).toHaveValue('');
366
+ expect(field()).toHaveAttribute('placeholder', 'Select a date');
367
+ rerender(
368
+ <DateInput label="Ship date" value="2026-03-21" onChange={() => {}} />,
369
+ );
370
+ expect(field()).toHaveValue('March 21, 2026');
371
+ });
372
+
373
+ it('honors a named format', () => {
374
+ render(
375
+ <DateInput
376
+ label="Ship date"
377
+ value="2026-03-21"
378
+ format="system_date"
379
+ onChange={() => {}}
380
+ />,
381
+ );
382
+ expect(field()).toHaveValue('2026-03-21');
383
+ });
384
+
385
+ it('honors a function format', () => {
386
+ render(
387
+ <DateInput
388
+ label="Ship date"
389
+ value="2026-03-21"
390
+ format={iso => `ISO:${iso}`}
391
+ onChange={() => {}}
392
+ />,
393
+ );
394
+ expect(field()).toHaveValue('ISO:2026-03-21');
395
+ });
396
+
397
+ it('honors a custom placeholder', () => {
398
+ render(
399
+ <DateInput
400
+ label="Ship date"
401
+ placeholder="Pick a day"
402
+ onChange={() => {}}
403
+ />,
404
+ );
405
+ expect(field()).toHaveAttribute('placeholder', 'Pick a day');
406
+ });
407
+
408
+ it('clears from the field', () => {
409
+ const onChange = vi.fn();
410
+ render(
411
+ <DateInput
412
+ label="Ship date"
413
+ value="2026-03-21"
414
+ hasClear
415
+ onChange={onChange}
416
+ />,
417
+ );
418
+ fireEvent.click(screen.getByRole('button', {name: /Clear Ship date/}));
419
+ expect(onChange).toHaveBeenCalledWith(undefined);
420
+ });
421
+
422
+ it('does not open the picker until the field is tapped', () => {
423
+ withLayout(() => {
424
+ render(<DateInput label="Ship date" onChange={() => {}} />);
425
+ expect(field()).toHaveAttribute('aria-expanded', 'false');
426
+ expect(screen.queryByRole('grid')).not.toBeInTheDocument();
427
+ fireEvent.click(field());
428
+ expect(field()).toHaveAttribute('aria-expanded', 'true');
429
+ expect(screen.getAllByRole('grid').length).toBeGreaterThan(0);
430
+ });
431
+ });
432
+
433
+ it('opens from the keyboard, APG combobox style', () => {
434
+ withLayout(() => {
435
+ render(<Controlled initial="2026-03-21" />);
436
+ fireEvent.keyDown(field(), {key: 'ArrowDown'});
437
+ expect(field()).toHaveAttribute('aria-expanded', 'true');
438
+ });
439
+ });
440
+
441
+ it('is not openable while disabled', () => {
442
+ render(<DateInput label="Ship date" isDisabled onChange={() => {}} />);
443
+ expect(field()).toBeDisabled();
444
+ fireEvent.click(field());
445
+ expect(screen.queryByRole('grid')).not.toBeInTheDocument();
446
+ });
447
+
448
+ it('stays focusable and explains itself when disabled with a reason', () => {
449
+ render(
450
+ <DateInput
451
+ label="Ship date"
452
+ isDisabled
453
+ disabledMessage="You need the Editor role"
454
+ onChange={() => {}}
455
+ />,
456
+ );
457
+ // aria-disabled, not disabled: the reason has to be reachable by keyboard.
458
+ expect(field()).not.toBeDisabled();
459
+ expect(field()).toHaveAttribute('aria-disabled', 'true');
460
+ fireEvent.click(field());
461
+ expect(screen.queryByRole('grid')).not.toBeInTheDocument();
462
+ });
463
+
464
+ it('renders label, description and status through Field', () => {
465
+ render(
466
+ <DateInput
467
+ label="Ship date"
468
+ description="When it leaves the warehouse"
469
+ status={{type: 'error', message: 'Pick a date'}}
470
+ onChange={() => {}}
471
+ />,
472
+ );
473
+ expect(screen.getByText('Ship date')).toBeInTheDocument();
474
+ expect(
475
+ screen.getByText('When it leaves the warehouse'),
476
+ ).toBeInTheDocument();
477
+ expect(screen.getByText('Pick a date')).toBeInTheDocument();
478
+ expect(field()).toHaveAttribute('aria-invalid', 'true');
479
+ expect(field().getAttribute('aria-describedby')).toBeTruthy();
480
+ });
481
+
482
+ it('marks required for assistive technology', () => {
483
+ render(<DateInput label="Ship date" isRequired onChange={() => {}} />);
484
+ expect(field()).toHaveAttribute('aria-required', 'true');
485
+ });
486
+
487
+ it('associates the label natively, so the field is named without ARIA', () => {
488
+ render(<DateInput label="Ship date" onChange={() => {}} />);
489
+ expect(screen.getByLabelText('Ship date')).toBe(field());
490
+ });
491
+
492
+ it('runs changeAction and shows a busy state', async () => {
493
+ let resolve: () => void = () => {};
494
+ const changeAction = vi.fn(
495
+ async () =>
496
+ new Promise<void>(r => {
497
+ resolve = r;
498
+ }),
499
+ );
500
+ withLayout(() => {
501
+ render(
502
+ <DateInput
503
+ label="Ship date"
504
+ value="2026-03-10"
505
+ min="2026-03-01"
506
+ max="2026-03-31"
507
+ onChange={() => {}}
508
+ changeAction={changeAction}
509
+ />,
510
+ );
511
+ fireEvent.click(field());
512
+ fireEvent.click(
513
+ within(pane('March 2026')).getByRole('button', {
514
+ name: /March 25, 2026/,
515
+ }),
516
+ );
517
+ });
518
+ expect(changeAction).toHaveBeenCalledWith('2026-03-25');
519
+ resolve();
520
+ });
521
+
522
+ it('drops the Field wrapper inside an InputGroup', () => {
523
+ render(
524
+ <InputGroup label="Range">
525
+ <DateInput label="Start" onChange={() => {}} />
526
+ </InputGroup>,
527
+ );
528
+ // Named by the group label plus its own, the way core's inputs are.
529
+ expect(field().getAttribute('aria-labelledby')).toBeTruthy();
530
+ });
531
+ });
532
+
533
+ // ---------------------------------------------------------------------------
534
+ // The picker surface
535
+ // ---------------------------------------------------------------------------
536
+
537
+ describe('DateInput — calendar surface', () => {
538
+ it('opens on the selected month', () => {
539
+ renderAndOpen();
540
+ expect(pane('March 2026')).toBeInTheDocument();
541
+ });
542
+
543
+ it('opens on the current month when there is no value', () => {
544
+ renderAndOpen(<Controlled />);
545
+ expect(pane('March 2026')).toBeInTheDocument();
546
+ });
547
+
548
+ it('mounts a window of months around the visible one, not the whole century', () => {
549
+ // The one test that wants the full default reach.
550
+ renderAndOpen(
551
+ <Controlled initial="2026-03-21" min={undefined} max={undefined} />,
552
+ );
553
+ const grids = screen.getAllByRole('grid');
554
+ expect(grids.length).toBeGreaterThan(1);
555
+ expect(grids.length).toBeLessThanOrEqual(7);
556
+ expect(grids.map(g => g.getAttribute('aria-label'))).toContain(
557
+ 'March 2026',
558
+ );
559
+ });
560
+
561
+ it('renders every month as six rows, so panes cannot differ in height', () => {
562
+ renderAndOpen(<Controlled initial="2026-02-01" />);
563
+ // February 2026 needs only five rows; the pane still has six.
564
+ const february = within(pane('February 2026'));
565
+ expect(february.getAllByRole('row')).toHaveLength(6);
566
+ expect(february.getAllByRole('gridcell')).toHaveLength(42);
567
+ });
568
+
569
+ it('commits the tapped day and LEAVES the sheet open', () => {
570
+ const onChange = vi.fn();
571
+ renderAndOpen(<Controlled initial="2026-03-21" onChange={onChange} />);
572
+ fireEvent.click(
573
+ within(pane('March 2026')).getByRole('button', {name: /March 25, 2026/}),
574
+ );
575
+ // The tap is the commit. Staying open lets a mistake be corrected in
576
+ // place, and a nearby date reconsidered, without reopening.
577
+ expect(onChange).toHaveBeenCalledWith('2026-03-25');
578
+ expect(field()).toHaveAttribute('aria-expanded', 'true');
579
+ });
580
+
581
+ it('lets a second tap correct the first, still without closing', () => {
582
+ const onChange = vi.fn();
583
+ renderAndOpen(<Controlled initial="2026-03-21" onChange={onChange} />);
584
+ const march = within(pane('March 2026'));
585
+ fireEvent.click(march.getByRole('button', {name: /March 25, 2026/}));
586
+ fireEvent.click(march.getByRole('button', {name: /March 26, 2026/}));
587
+ expect(onChange).toHaveBeenLastCalledWith('2026-03-26');
588
+ expect(field()).toHaveAttribute('aria-expanded', 'true');
589
+ });
590
+
591
+ it('Save closes the sheet without touching the value', () => {
592
+ const onChange = vi.fn();
593
+ renderAndOpen(<Controlled initial="2026-03-21" onChange={onChange} />);
594
+ fireEvent.click(
595
+ within(pane('March 2026')).getByRole('button', {name: /March 25, 2026/}),
596
+ );
597
+ onChange.mockClear();
598
+ fireEvent.click(screen.getByRole('button', {name: 'Save'}));
599
+ expect(field()).toHaveAttribute('aria-expanded', 'false');
600
+ // Named for what it means to someone finishing a form, not for what it
601
+ // does internally: the value was already committed by the tap that chose
602
+ // it, so this fires nothing of its own.
603
+ expect(onChange).not.toHaveBeenCalled();
604
+ expect(field()).toHaveValue('March 25, 2026');
605
+ });
606
+
607
+ it('Save closes even with no date chosen, committing nothing', () => {
608
+ const onChange = vi.fn();
609
+ withLayout(() => {
610
+ render(<DateInput label="Ship date" onChange={onChange} />);
611
+ fireEvent.click(field());
612
+ fireEvent.click(screen.getByRole('button', {name: 'Save'}));
613
+ });
614
+ expect(field()).toHaveAttribute('aria-expanded', 'false');
615
+ expect(onChange).not.toHaveBeenCalled();
616
+ });
617
+
618
+ it('has Save in the footer and no Today button', () => {
619
+ renderAndOpen();
620
+ expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument();
621
+ // "Today" moved the calendar to the current month WITHOUT selecting it,
622
+ // which read as broken — the one thing the name promises is the thing it
623
+ // did not do. Removed until it can be navigate-or-select on purpose.
624
+ expect(screen.queryByRole('button', {name: 'Today'})).toBeNull();
625
+ });
626
+
627
+ it('marks the selection and today', () => {
628
+ renderAndOpen();
629
+ const march = within(pane('March 2026'));
630
+ expect(
631
+ march
632
+ .getByRole('button', {name: /March 21, 2026/})
633
+ .closest('[role="gridcell"]'),
634
+ ).toHaveAttribute('aria-selected', 'true');
635
+ expect(march.getByRole('button', {name: /March 15, 2026/})).toHaveAttribute(
636
+ 'aria-current',
637
+ 'date',
638
+ );
639
+ });
640
+
641
+ it('leaves exactly one day per month tab-reachable', () => {
642
+ renderAndOpen();
643
+ const tabbable = within(pane('March 2026'))
644
+ .getAllByRole('button')
645
+ .filter(b => b.getAttribute('tabindex') === '0');
646
+ expect(tabbable).toHaveLength(1);
647
+ // The selected day, when the month holds one.
648
+ expect(tabbable[0]).toHaveAttribute('data-date', '2026-03-21');
649
+ });
650
+
651
+ it('pages a month with the header arrows', () => {
652
+ renderAndOpen();
653
+ const header = () =>
654
+ document.querySelector<HTMLElement>('[data-title="month-year"]')!;
655
+ expect(header()).toHaveTextContent('March 2026');
656
+ fireEvent.click(screen.getByRole('button', {name: 'Next month'}));
657
+ expect(header()).toHaveTextContent('April 2026');
658
+ fireEvent.click(screen.getByRole('button', {name: 'Previous month'}));
659
+ expect(header()).toHaveTextContent('March 2026');
660
+ });
661
+
662
+ /**
663
+ * A programmatic scroll must not report its own arrival as if the user had
664
+ * scrolled there. Nothing needs the report — whatever asked for the scroll
665
+ * already knows the month — and trusting it turns a steer into a cycle.
666
+ *
667
+ * The case that bit: a wheel commit steers this scroller while it is hidden
668
+ * behind the wheels, and a hidden scroller does not reliably stay put. On
669
+ * iOS the position is re-snapped when the panel becomes visible again,
670
+ * firing a scroll just as the wheels close and reports start being trusted
671
+ * again — so the month drifted on the way back to the calendar.
672
+ */
673
+ it('does not report a scroll it was told to make', async () => {
674
+ await withLayout(async () => {
675
+ render(
676
+ <Controlled initial="2026-03-21" min="2026-01-01" max="2026-12-31" />,
677
+ );
678
+ fireEvent.click(field());
679
+ const header = () =>
680
+ document.querySelector<HTMLElement>('[data-title="month-year"]')!;
681
+ const scroller = document.querySelector<HTMLElement>(
682
+ '[data-scroller="months"]',
683
+ )!;
684
+
685
+ // An arrow steers it to April. The scroll that lands there is our own
686
+ // doing, so whatever it reports must not move the month again.
687
+ fireEvent.click(screen.getByRole('button', {name: 'Next month'}));
688
+ expect(header()).toHaveTextContent('April 2026');
689
+ // Row 3 of a range starting in January is April: the month the steer
690
+ // was aiming at, arriving.
691
+ scroller.scrollLeft = 3 * SCROLLPORT_WIDTH;
692
+ fireEvent.scroll(scroller);
693
+ await frame();
694
+ expect(header()).toHaveTextContent('April 2026');
695
+
696
+ // A finger ends the steering: from here the months it passes are the
697
+ // user's, and every one of them counts.
698
+ // jsdom has no constructible TouchEvent, and a bare Event carries no
699
+ // `touches` — which crashes the gesture hook that reads `touches[0]`.
700
+ // That crash is the fake event's fault, not the hook's: a browser's
701
+ // touchstart always has the list.
702
+ const touchStart = new Event('touchstart', {bubbles: true});
703
+ const point = {identifier: 1, clientX: 100, clientY: 100};
704
+ Object.defineProperties(touchStart, {
705
+ touches: {value: [point]},
706
+ targetTouches: {value: [point]},
707
+ changedTouches: {value: [point]},
708
+ });
709
+ scroller.dispatchEvent(touchStart);
710
+ scroller.scrollLeft = 5 * SCROLLPORT_WIDTH;
711
+ fireEvent.scroll(scroller);
712
+ // The report is made from inside a rAF callback, so the state update
713
+ // lands outside the act() that fireEvent wraps.
714
+ await waitFor(() => expect(header()).toHaveTextContent('June 2026'));
715
+ });
716
+ });
717
+
718
+ /**
719
+ * An arrow with nowhere to go is hidden, not greyed. A disabled control
720
+ * still says "this is a thing you could do", and at the edge of a range it
721
+ * is not — there is no state the user can reach where it becomes
722
+ * available, so a permanently greyed chevron just reads as broken.
723
+ *
724
+ * It keeps its box, though: `visibility: hidden` rather than unmounting,
725
+ * so the remaining arrow does not slide sideways as an edge is reached.
726
+ */
727
+ it('hides an arrow at the end of the reachable range', () => {
728
+ renderAndOpen(
729
+ <Controlled initial="2026-03-10" min="2026-03-01" max="2026-03-31" />,
730
+ );
731
+ // Queried by attribute, not by role: `visibility: hidden` is exactly what
732
+ // strips an element of its accessible name, so a role query cannot see
733
+ // these — which is the point of the assertion below.
734
+ const arrow = (name: string) =>
735
+ document.querySelector<HTMLElement>(
736
+ `dialog[open] button[aria-label="${name}"]`,
737
+ );
738
+ for (const name of ['Previous month', 'Next month']) {
739
+ // Still mounted, so the header cannot reflow...
740
+ expect(arrow(name)).toBeInTheDocument();
741
+ expect(arrow(name)).toBeDisabled();
742
+ // ...but gone from the accessibility tree, and so unreachable.
743
+ expect(screen.queryByRole('button', {name})).toBeNull();
744
+ }
745
+ });
746
+
747
+ it('shows both arrows when there is somewhere to go in each direction', () => {
748
+ renderAndOpen(
749
+ <Controlled initial="2026-03-10" min="2026-01-01" max="2026-12-31" />,
750
+ );
751
+ expect(
752
+ screen.getByRole('button', {name: 'Previous month'}),
753
+ ).toBeInTheDocument();
754
+ expect(
755
+ screen.getByRole('button', {name: 'Next month'}),
756
+ ).toBeInTheDocument();
757
+ });
758
+
759
+ it('does not change the selection when paging', () => {
760
+ const onChange = vi.fn();
761
+ renderAndOpen(<Controlled initial="2026-03-21" onChange={onChange} />);
762
+ fireEvent.click(screen.getByRole('button', {name: 'Next month'}));
763
+ // Navigating is not selecting — the mistake the old Today button made.
764
+ expect(onChange).not.toHaveBeenCalled();
765
+ expect(field()).toHaveValue('March 21, 2026');
766
+ });
767
+
768
+ it('disables days outside min/max and refuses to commit them', () => {
769
+ const onChange = vi.fn();
770
+ renderAndOpen(
771
+ <Controlled
772
+ initial="2026-03-10"
773
+ min="2026-03-05"
774
+ max="2026-03-20"
775
+ onChange={onChange}
776
+ />,
777
+ );
778
+ const outOfRange = within(pane('March 2026')).getByRole('button', {
779
+ name: /March 25, 2026/,
780
+ });
781
+ expect(outOfRange).toHaveAttribute('aria-disabled', 'true');
782
+ fireEvent.click(outOfRange);
783
+ expect(onChange).not.toHaveBeenCalled();
784
+ });
785
+
786
+ it('honors custom date constraints', () => {
787
+ renderAndOpen(
788
+ <Controlled
789
+ initial="2026-03-10"
790
+ dateConstraints={[date => date.getDay() !== 0]}
791
+ />,
792
+ );
793
+ // 2026-03-01 is a Sunday.
794
+ expect(
795
+ within(pane('March 2026')).getByRole('button', {name: /March 1, 2026/}),
796
+ ).toHaveAttribute('aria-disabled', 'true');
797
+ });
798
+
799
+ /**
800
+ * Spill days, the way the desktop calendar has them.
801
+ *
802
+ * These were left out at first, on the theory that a horizontal scroller
803
+ * would show the same date twice — greyed at the foot of one pane and
804
+ * again at the head of the next. Measured, that cannot happen: both panes
805
+ * are exactly the scrollport wide and share one 7-column grid, so a given
806
+ * weekday column is only ever visible in ONE pane at a time, and a date
807
+ * always sits in its weekday's column. Swept across a full pane boundary
808
+ * in 10% steps — 42 dates on screen throughout, zero duplicated.
809
+ */
810
+ it('renders adjacent-month days, muted, like the desktop calendar', () => {
811
+ renderAndOpen();
812
+ const march = within(pane('March 2026'));
813
+ // Every cell in the 6x7 grid is now a real day.
814
+ expect(march.getAllByRole('gridcell')).toHaveLength(42);
815
+ expect(march.getAllByRole('button')).toHaveLength(42);
816
+ // March 2026 begins on a Sunday, so it spills only forwards.
817
+ expect(
818
+ march.getByRole('button', {name: /April 1, 2026/}),
819
+ ).toBeInTheDocument();
820
+ });
821
+
822
+ it('spills backwards too, on a month that does not start the week', () => {
823
+ renderAndOpen(
824
+ <Controlled initial="2026-04-15" min="2026-01-01" max="2026-12-31" />,
825
+ );
826
+ const april = within(pane('April 2026'));
827
+ // April 2026 starts on a Wednesday: the first row opens with late March.
828
+ expect(
829
+ april.getByRole('button', {name: /March 29, 2026/}),
830
+ ).toBeInTheDocument();
831
+ expect(april.getAllByRole('button')).toHaveLength(42);
832
+ });
833
+
834
+ /**
835
+ * A spilled day is shown, not offered.
836
+ *
837
+ * It was pickable at first, on the reasoning that a date you can see is a
838
+ * date you should be able to tap. The desktop calendar disagrees — it makes
839
+ * its own outside days unselectable — and on a pane that IS the month, so
840
+ * does the interaction: committing April 1 from March's pane would move the
841
+ * calendar out from under the thumb that just tapped it. The swipe and the
842
+ * arrows say "next month" without that ambiguity.
843
+ */
844
+ it('does not commit an adjacent-month day', () => {
845
+ const onChange = vi.fn();
846
+ renderAndOpen(<Controlled initial="2026-03-21" onChange={onChange} />);
847
+ const april1 = within(pane('March 2026')).getByRole('button', {
848
+ name: /April 1, 2026/,
849
+ });
850
+ expect(april1).toHaveAttribute('aria-disabled', 'true');
851
+ fireEvent.click(april1);
852
+ expect(onChange).not.toHaveBeenCalled();
853
+ });
854
+
855
+ /**
856
+ * Being outside is enough on its own — a spill day inside the allowed range
857
+ * is still not a choice. Worth its own case because `isDateDisabled` says
858
+ * nothing about April 1 here, so only the outside test can be what disables
859
+ * it.
860
+ */
861
+ it('disables a spilled day the range would otherwise allow', () => {
862
+ renderAndOpen(
863
+ <Controlled initial="2026-03-21" min="2026-01-01" max="2026-12-31" />,
864
+ );
865
+ const march = within(pane('March 2026'));
866
+ // In range, and in its own pane it is perfectly pickable.
867
+ expect(
868
+ within(pane('April 2026')).getByRole('button', {name: /April 1, 2026/}),
869
+ ).not.toHaveAttribute('aria-disabled');
870
+ // Borrowed by March, it is not.
871
+ expect(march.getByRole('button', {name: /April 1, 2026/})).toHaveAttribute(
872
+ 'aria-disabled',
873
+ 'true',
874
+ );
875
+ });
876
+
877
+ /**
878
+ * The selection and today's ring belong to the month that owns the date.
879
+ * Calendar guards both on `!isOutside`; without the same guard a date would
880
+ * wear its puck twice, once in its own pane and once in the neighbour that
881
+ * only borrows it.
882
+ */
883
+ it('leaves the puck and the today ring with the month that owns the day', () => {
884
+ renderAndOpen(
885
+ <Controlled initial="2026-04-01" min="2026-01-01" max="2026-12-31" />,
886
+ );
887
+ const spilled = within(pane('March 2026')).getByRole('button', {
888
+ name: /April 1, 2026/,
889
+ });
890
+ const owned = within(pane('April 2026')).getByRole('button', {
891
+ name: /April 1, 2026/,
892
+ });
893
+ // Same date, same label — and only one of them is dressed as selected.
894
+ expect(owned.className).not.toBe(spilled.className);
895
+ expect(
896
+ within(pane('April 2026')).getAllByRole('gridcell', {selected: true}),
897
+ ).toHaveLength(1);
898
+ expect(
899
+ within(pane('March 2026')).queryAllByRole('gridcell', {selected: true}),
900
+ ).toHaveLength(0);
901
+ });
902
+
903
+ /**
904
+ * A date shown in two panes must not be two tab stops — the pane that owns
905
+ * it, and the neighbour that merely spills it. That holds because
906
+ * `tabbableISO` is resolved per pane and only names dates in that pane's
907
+ * own month, so it is worth pinning: the case below is the one that would
908
+ * break if it ever became global, since April 1 is both the selection and
909
+ * a spill day in March's pane.
910
+ */
911
+ it('gives a spilled date no tab stop in the pane that borrows it', () => {
912
+ renderAndOpen(
913
+ <Controlled initial="2026-04-01" min="2026-01-01" max="2026-12-31" />,
914
+ );
915
+ const tabbableIn = (label: string) =>
916
+ within(pane(label))
917
+ .getAllByRole('button')
918
+ .filter(b => b.getAttribute('tabindex') === '0')
919
+ .map(b => b.getAttribute('aria-label'));
920
+
921
+ // April owns the date, so its stop is the date itself.
922
+ expect(tabbableIn('April 2026')).toHaveLength(1);
923
+ expect(tabbableIn('April 2026')[0]).toMatch(/April 1, 2026/);
924
+ // March shows the same date, but its stop falls back to a March date —
925
+ // every pane keeps exactly one, and never on a day it does not own.
926
+ expect(tabbableIn('March 2026')).toHaveLength(1);
927
+ expect(tabbableIn('March 2026')[0]).toMatch(/March/);
928
+ });
929
+
930
+ const weekdayNames = () =>
931
+ [...weekdayRow().children].map(c => c.textContent?.trim());
932
+
933
+ /**
934
+ * Three letters, where Calendar's own header uses two.
935
+ *
936
+ * The sheet is full width — ~51px a column against Calendar's popover — so
937
+ * there is room for the form people actually read, and a picker driven by
938
+ * thumb should not make anyone decode "Tu" against "Th".
939
+ *
940
+ * Exact equality, not `toHaveTextContent`: that matches substrings, so it
941
+ * passes against "Sun" while asserting "Su" and would not have noticed this
942
+ * change at all.
943
+ */
944
+ it('labels the columns with three-letter weekday names', () => {
945
+ renderAndOpen();
946
+ expect(weekdayNames()).toEqual([
947
+ 'Sun',
948
+ 'Mon',
949
+ 'Tue',
950
+ 'Wed',
951
+ 'Thu',
952
+ 'Fri',
953
+ 'Sat',
954
+ ]);
955
+ });
956
+
957
+ it('rotates the weekday header with weekStartsOn', () => {
958
+ renderAndOpen(<Controlled initial="2026-03-21" weekStartsOn={1} />);
959
+ expect(weekdayNames()).toEqual([
960
+ 'Mon',
961
+ 'Tue',
962
+ 'Wed',
963
+ 'Thu',
964
+ 'Fri',
965
+ 'Sat',
966
+ 'Sun',
967
+ ]);
968
+ });
969
+
970
+ it('accepts weekStartsOn as a day name, like Calendar and DateInput', () => {
971
+ renderAndOpen(<Controlled initial="2026-03-21" weekStartsOn="sat" />);
972
+ expect(weekdayNames()[0]).toBe('Sat');
973
+ // The columns rotate with the header, or the grid would be mislabelled.
974
+ expect(
975
+ within(pane('March 2026'))
976
+ .getAllByRole('button')[0]
977
+ .getAttribute('aria-label'),
978
+ ).toMatch(/February 28, 2026|March/);
979
+ });
980
+
981
+ it('moves keyboard focus by date, across the month boundary', () => {
982
+ renderAndOpen(<Controlled initial="2026-03-31" />);
983
+ const last = within(pane('March 2026')).getByRole('button', {
984
+ name: /March 31, 2026/,
985
+ });
986
+ last.focus();
987
+ fireEvent.keyDown(last, {key: 'ArrowRight'});
988
+ expect(document.activeElement).toHaveAttribute('data-date', '2026-04-01');
989
+ });
990
+
991
+ it('moves a week at a time with the vertical arrows', () => {
992
+ renderAndOpen(<Controlled initial="2026-03-10" />);
993
+ const day = within(pane('March 2026')).getByRole('button', {
994
+ name: /March 10, 2026/,
995
+ });
996
+ day.focus();
997
+ fireEvent.keyDown(day, {key: 'ArrowDown'});
998
+ expect(document.activeElement).toHaveAttribute('data-date', '2026-03-17');
999
+ });
1000
+ });
1001
+
1002
+ // ---------------------------------------------------------------------------
1003
+ // Resting between two months — the iOS snap failure
1004
+ // ---------------------------------------------------------------------------
1005
+
1006
+ /**
1007
+ * `scroll-snap-type: mandatory` is supposed to make all of this unnecessary,
1008
+ * and on a static list it does. This list is virtualized: seven panes exist
1009
+ * out of twelve hundred, and THE PANES ARE THE SNAP AREAS — so every month
1010
+ * the finger crosses mounts one and unmounts another, mid-fling.
1011
+ *
1012
+ * iOS scrolls off the main thread. It picks a landing place from the snap
1013
+ * points it knows about at the time, and a React re-render that lands after
1014
+ * that decision moves them; the scroller comes to rest where no snap point
1015
+ * exists any more and nothing re-snaps it. Reported from a device as the
1016
+ * calendar sitting between two months with the weekday header still square —
1017
+ * which is exactly the shape of it: the grid is not skewed, the scrollport is
1018
+ * parked a couple of columns into a pane, so the left of March and the right
1019
+ * of April are on screen under one Sun-to-Sat header.
1020
+ *
1021
+ * Chrome never shows it, because it snaps again after the mutation. jsdom
1022
+ * implements no snapping at all, which makes it a fine place to test the
1023
+ * correction: the scroller can simply be PUT at a bad offset, exactly as iOS
1024
+ * leaves it, and the settle has to fix it.
1025
+ */
1026
+ describe('DateInput — a rest position between two months', () => {
1027
+ /** The row March 2026 occupies in a range that opens in January 2024. */
1028
+ const MARCH_2026_ROW = 26;
1029
+ const FIVE_YEARS = {min: '2024-01-01', max: '2028-12-31'} as const;
1030
+
1031
+ /** A touchstart carrying the list a real one always has. */
1032
+ const touchEvent = (type: string) => {
1033
+ const event = new Event(type, {bubbles: true});
1034
+ const point = {identifier: 1, clientX: 100, clientY: 100};
1035
+ Object.defineProperties(event, {
1036
+ touches: {value: type === 'touchend' ? [] : [point]},
1037
+ targetTouches: {value: type === 'touchend' ? [] : [point]},
1038
+ changedTouches: {value: [point]},
1039
+ });
1040
+ return event;
1041
+ };
1042
+
1043
+ /** Drag, release, and let the quiet period elapse. */
1044
+ const swipeTo = async (scroller: HTMLElement, offset: number) => {
1045
+ scroller.dispatchEvent(touchEvent('touchstart'));
1046
+ scroller.scrollLeft = offset;
1047
+ fireEvent.scroll(scroller);
1048
+ scroller.dispatchEvent(touchEvent('touchend'));
1049
+ };
1050
+
1051
+ const openCalendar = () => {
1052
+ render(<Controlled initial="2026-03-21" {...FIVE_YEARS} />);
1053
+ fireEvent.click(screen.getByLabelText('Event date'));
1054
+ const scroller = document.querySelector<HTMLElement>(
1055
+ '[data-scroller="months"]',
1056
+ )!;
1057
+ const scrollTo = vi.mocked(Element.prototype.scrollTo);
1058
+ scrollTo.mockClear();
1059
+ return {scroller, scrollTo};
1060
+ };
1061
+
1062
+ it('puts the calendar back on a pane once the swipe is over', async () => {
1063
+ await withLayout(async () => {
1064
+ const {scroller, scrollTo} = openCalendar();
1065
+ // Two columns in — what the device screenshot showed.
1066
+ const stray = Math.round((SCROLLPORT_WIDTH * 2) / 7);
1067
+ await swipeTo(scroller, MARCH_2026_ROW * SCROLLPORT_WIDTH + stray);
1068
+
1069
+ await waitFor(() =>
1070
+ expect(scrollTo).toHaveBeenCalledWith(
1071
+ expect.objectContaining({left: MARCH_2026_ROW * SCROLLPORT_WIDTH}),
1072
+ ),
1073
+ );
1074
+ });
1075
+ });
1076
+
1077
+ it('goes to the nearer pane, not back the way it came', async () => {
1078
+ await withLayout(async () => {
1079
+ const {scroller, scrollTo} = openCalendar();
1080
+ // Three quarters of the way to April: April is the honest answer, and a
1081
+ // correction that always rounded down would drag the user backwards.
1082
+ await swipeTo(
1083
+ scroller,
1084
+ MARCH_2026_ROW * SCROLLPORT_WIDTH + SCROLLPORT_WIDTH * 0.75,
1085
+ );
1086
+
1087
+ await waitFor(() =>
1088
+ expect(scrollTo).toHaveBeenCalledWith(
1089
+ expect.objectContaining({
1090
+ left: (MARCH_2026_ROW + 1) * SCROLLPORT_WIDTH,
1091
+ }),
1092
+ ),
1093
+ );
1094
+ });
1095
+ });
1096
+
1097
+ /**
1098
+ * A scroller the browser snapped for itself must be left alone. Correcting
1099
+ * it anyway would mean every swipe on Chrome — where snapping works — ended
1100
+ * with a second, pointless scroll.
1101
+ */
1102
+ it('leaves a scroller that snapped properly alone', async () => {
1103
+ await withLayout(async () => {
1104
+ const {scroller, scrollTo} = openCalendar();
1105
+ await swipeTo(scroller, (MARCH_2026_ROW + 2) * SCROLLPORT_WIDTH);
1106
+
1107
+ // Long enough that the settle has certainly run.
1108
+ await new Promise(resolve => setTimeout(resolve, SCROLL_QUIET_MS * 2));
1109
+ expect(scrollTo).not.toHaveBeenCalled();
1110
+ });
1111
+ });
1112
+
1113
+ /**
1114
+ * Sub-pixel drift is the browser's own rounding on a fractional viewport,
1115
+ * not a failed snap. Correcting it would fire a scroll after every gesture
1116
+ * on any device whose width is not a whole number of pixels.
1117
+ */
1118
+ it('ignores sub-pixel drift', async () => {
1119
+ await withLayout(async () => {
1120
+ const {scroller, scrollTo} = openCalendar();
1121
+ await swipeTo(scroller, MARCH_2026_ROW * SCROLLPORT_WIDTH + 0.4);
1122
+
1123
+ await new Promise(resolve => setTimeout(resolve, SCROLL_QUIET_MS * 2));
1124
+ expect(scrollTo).not.toHaveBeenCalled();
1125
+ });
1126
+ });
1127
+
1128
+ /**
1129
+ * The correction waits for the finger. Firing it mid-drag would fight the
1130
+ * hand that is still moving the scroller — the same mistake that made the
1131
+ * wheels climb a month at a time on iOS, and the reason `useScrollSettle`
1132
+ * waits for a release rather than for quiet alone.
1133
+ */
1134
+ it('does not correct while the finger is still down', async () => {
1135
+ await withLayout(async () => {
1136
+ const {scroller, scrollTo} = openCalendar();
1137
+ scroller.dispatchEvent(touchEvent('touchstart'));
1138
+ scroller.scrollLeft = MARCH_2026_ROW * SCROLLPORT_WIDTH + 120;
1139
+ fireEvent.scroll(scroller);
1140
+
1141
+ await new Promise(resolve => setTimeout(resolve, SCROLL_QUIET_MS * 2));
1142
+ expect(scrollTo).not.toHaveBeenCalled();
1143
+
1144
+ // And on release it does the correction it was holding back.
1145
+ scroller.dispatchEvent(touchEvent('touchend'));
1146
+ await waitFor(() => expect(scrollTo).toHaveBeenCalled());
1147
+ });
1148
+ });
1149
+
1150
+ /**
1151
+ * The one that actually reverses a swipe, and the reason the correction
1152
+ * re-checks stillness rather than trusting the quiet period.
1153
+ *
1154
+ * iOS runs its own snap animation for ~150-300ms after the finger lifts,
1155
+ * and fires scroll events irregularly while it does — a gap longer than the
1156
+ * quiet period is routine in the slow tail. The settle lands mid-animation,
1157
+ * reads an offset still travelling toward April, rounds THAT to the nearest
1158
+ * pane (still March, since the animation is not yet halfway), and drags the
1159
+ * calendar back where it came from. Swipe forward, get pulled backward.
1160
+ *
1161
+ * Simulated by moving the scroller between the two samples, which is what
1162
+ * an animation in flight looks like from here.
1163
+ */
1164
+ it('does not correct a scroller that is still travelling', async () => {
1165
+ await withLayout(async () => {
1166
+ const {scroller, scrollTo} = openCalendar();
1167
+ // A quarter of the way to April, and still going.
1168
+ let offset = MARCH_2026_ROW * SCROLLPORT_WIDTH + SCROLLPORT_WIDTH * 0.25;
1169
+ Object.defineProperty(scroller, 'scrollLeft', {
1170
+ configurable: true,
1171
+ get: () => {
1172
+ // Every read advances it, the way an in-flight animation does.
1173
+ offset += 8;
1174
+ return offset;
1175
+ },
1176
+ set: (value: number) => {
1177
+ offset = value;
1178
+ },
1179
+ });
1180
+
1181
+ await swipeTo(scroller, offset);
1182
+ await new Promise(resolve => setTimeout(resolve, SCROLL_QUIET_MS * 3));
1183
+ // Nothing. Correcting here would have sent it back to March, undoing a
1184
+ // swipe the browser was already completing.
1185
+ expect(scrollTo).not.toHaveBeenCalled();
1186
+ });
1187
+ });
1188
+ });
1189
+
1190
+ // ---------------------------------------------------------------------------
1191
+ // Month / year wheels
1192
+ // ---------------------------------------------------------------------------
1193
+
1194
+ describe('DateInput — month/year wheels', () => {
1195
+ /**
1196
+ * The header title. Queried by attribute rather than by role and accessible
1197
+ * name: every role query in here walks a tree of ~150 elements and computes
1198
+ * a name for each, which dominates the runtime of these tests.
1199
+ */
1200
+ const title = () =>
1201
+ document.querySelector<HTMLElement>('[data-title="month-year"]')!;
1202
+
1203
+ const openWheels = () => fireEvent.click(title());
1204
+
1205
+ /** The swap panel holding the calendar, or the one holding the wheels. */
1206
+ const panel = (which: 'calendar' | 'wheels') =>
1207
+ document.querySelector(`[data-panel="${which}"]`)!;
1208
+
1209
+ const ONE_YEAR = {min: '2026-01-01', max: '2026-12-31'} as const;
1210
+ const FIVE_YEARS = {min: '2024-01-01', max: '2028-12-31'} as const;
1211
+
1212
+ it('the header title opens them, and says so', () => {
1213
+ renderAndOpen();
1214
+ expect(title()).toHaveAttribute('aria-expanded', 'false');
1215
+ expect(panel('wheels')).toHaveAttribute('inert');
1216
+ expect(panel('calendar')).not.toHaveAttribute('inert');
1217
+ openWheels();
1218
+ expect(title()).toHaveAttribute('aria-expanded', 'true');
1219
+ expect(panel('wheels')).not.toHaveAttribute('inert');
1220
+ expect(panel('calendar')).toHaveAttribute('inert');
1221
+ expect(screen.getByRole('listbox', {name: 'Month'})).toBeInTheDocument();
1222
+ expect(screen.getByRole('listbox', {name: 'Year'})).toBeInTheDocument();
1223
+ });
1224
+
1225
+ /**
1226
+ * The arrows step the calendar, and the calendar is not on screen. They
1227
+ * keep their layout box rather than unmounting: they are the tallest thing
1228
+ * in the header on a coarse pointer (44px against the title's 36), so
1229
+ * dropping them would shorten it and shift the sheet mid-cross-fade.
1230
+ */
1231
+ it('hides the month arrows while the wheels are up', () => {
1232
+ renderAndOpen();
1233
+ const arrows = document.querySelector<HTMLElement>(
1234
+ '[data-arrows="months"]',
1235
+ )!;
1236
+ expect(arrows).not.toHaveAttribute('inert');
1237
+
1238
+ openWheels();
1239
+ expect(arrows).toHaveAttribute('inert');
1240
+ // Still in the layout, so the header cannot change height.
1241
+ expect(arrows).toBeInTheDocument();
1242
+
1243
+ fireEvent.click(title());
1244
+ expect(arrows).not.toHaveAttribute('inert');
1245
+ });
1246
+
1247
+ it('offers twelve months, with the current one selected', () => {
1248
+ renderAndOpen();
1249
+ openWheels();
1250
+ const options = within(
1251
+ screen.getByRole('listbox', {name: 'Month'}),
1252
+ ).getAllByRole('option');
1253
+ expect(options).toHaveLength(12);
1254
+ expect(options[2]).toHaveTextContent('March');
1255
+ expect(options[2]).toHaveAttribute('aria-selected', 'true');
1256
+ });
1257
+
1258
+ it('tapping a wheel row moves the calendar to that month', () => {
1259
+ renderAndOpen(<Controlled initial="2026-03-21" {...ONE_YEAR} />);
1260
+ openWheels();
1261
+ fireEvent.click(
1262
+ within(screen.getByRole('listbox', {name: 'Month'})).getByText(
1263
+ 'September',
1264
+ ),
1265
+ );
1266
+ expect(title()).toHaveTextContent('September 2026');
1267
+ });
1268
+
1269
+ /**
1270
+ * A wheel commit steers the hidden calendar, and the calendar reports the
1271
+ * month it lands on. Taking that report back while the wheels are open
1272
+ * closes a cycle: commit -> echo -> the echo moves the wheel's selected row
1273
+ * -> the wheel is repositioned -> that scroll reads as another commit.
1274
+ *
1275
+ * Whether it converges comes down to how precisely a browser says
1276
+ * "scrolling stopped". Chrome has `scrollend` and settles at once; iOS
1277
+ * below Safari 26 has none, and its momentum runs on after the finger
1278
+ * lifts, so each lap committed the next month along and the month climbed
1279
+ * on its own — reported from a device, invisible in Chrome.
1280
+ *
1281
+ * Driven here through the calendar's real scroll listener, which needs a
1282
+ * measured scrollport (`withLayout`) and a `scrollLeft` far enough to land
1283
+ * on another pane, then a frame for the rAF throttle. No timing assumptions:
1284
+ * the echo either reaches the month or it does not.
1285
+ */
1286
+ it('ignores the calendar echo while the wheels are steering it', async () => {
1287
+ await withLayout(async () => {
1288
+ render(<Controlled initial="2026-03-21" {...FIVE_YEARS} />);
1289
+ fireEvent.click(field());
1290
+ openWheels();
1291
+ fireEvent.click(
1292
+ within(screen.getByRole('listbox', {name: 'Month'})).getByText(
1293
+ 'January',
1294
+ ),
1295
+ );
1296
+ expect(title()).toHaveTextContent('January 2026');
1297
+
1298
+ // The calendar, scrolling to January behind the wheels, reports each
1299
+ // month it passes. None of it may move the month the wheels just set.
1300
+ const scroller = document.querySelector<HTMLElement>(
1301
+ '[data-scroller="months"]',
1302
+ )!;
1303
+ for (const row of [5, 6, 7, 8]) {
1304
+ scroller.scrollLeft = row * SCROLLPORT_WIDTH;
1305
+ fireEvent.scroll(scroller);
1306
+ await frame();
1307
+ }
1308
+ expect(title()).toHaveTextContent('January 2026');
1309
+ });
1310
+ // The other direction — the calendar reporting its month when it IS the
1311
+ // surface — is what every test in the calendar-surface block above
1312
+ // depends on, so it is covered rather than restated here.
1313
+ });
1314
+
1315
+ /**
1316
+ * The reveal case, which is the one that was reported from a device.
1317
+ *
1318
+ * A wheel commit steers the calendar while it is hidden behind the wheels,
1319
+ * and `visibility: hidden` keeps the layout box but does not guarantee the
1320
+ * scroll position survives: iOS re-snaps the scroller when it becomes
1321
+ * visible again, and not necessarily onto the pane it was put on. That
1322
+ * fires a scroll exactly as the wheels close and reports start being
1323
+ * trusted again — so the month drifted on the way back to the dates.
1324
+ *
1325
+ * Simulated here by moving the hidden scroller somewhere it was never sent,
1326
+ * which is what the re-snap amounts to.
1327
+ */
1328
+ it('holds the month when the hidden calendar is re-snapped on reveal', async () => {
1329
+ await withLayout(async () => {
1330
+ render(<Controlled initial="2026-03-21" {...FIVE_YEARS} />);
1331
+ fireEvent.click(field());
1332
+ openWheels();
1333
+ fireEvent.click(
1334
+ within(screen.getByRole('listbox', {name: 'Month'})).getByText(
1335
+ 'January',
1336
+ ),
1337
+ );
1338
+ expect(title()).toHaveTextContent('January 2026');
1339
+
1340
+ // iOS moves the hidden scroller off the pane it was steered to.
1341
+ const scroller = document.querySelector<HTMLElement>(
1342
+ '[data-scroller="months"]',
1343
+ )!;
1344
+ scroller.scrollLeft += 4 * SCROLLPORT_WIDTH;
1345
+
1346
+ // Back to the dates. Two things have to hold: the stray position must
1347
+ // not become the month, and the calendar must be put back on the pane
1348
+ // the month names — or the header would say January over a different
1349
+ // month's grid.
1350
+ const scrollTo = vi.mocked(Element.prototype.scrollTo);
1351
+ scrollTo.mockClear();
1352
+ fireEvent.click(title());
1353
+ fireEvent.scroll(scroller);
1354
+ await frame();
1355
+ await frame();
1356
+ expect(title()).toHaveTextContent('January 2026');
1357
+
1358
+ // January 2026 is row 24 of a range that starts in January 2024.
1359
+ expect(scrollTo).toHaveBeenCalledWith(
1360
+ expect.objectContaining({left: 24 * SCROLLPORT_WIDTH}),
1361
+ );
1362
+ });
1363
+ });
1364
+
1365
+ /**
1366
+ * The wheels are a detour to reach a far month, not a mode. Reopening into
1367
+ * them would answer a question the user has not asked yet, and hide the
1368
+ * dates they came back for behind another tap.
1369
+ */
1370
+ it('always reopens on the calendar, whatever was showing last time', () => {
1371
+ renderAndOpen();
1372
+ openWheels();
1373
+ expect(title()).toHaveAttribute('aria-expanded', 'true');
1374
+
1375
+ // Dismissed from the wheels, where Done is deliberately not offered —
1376
+ // the handle, the scrim and Escape are the ways out.
1377
+ fireEvent.keyDown(document.querySelector('dialog[open]')!, {key: 'Escape'});
1378
+ expect(field()).toHaveAttribute('aria-expanded', 'false');
1379
+
1380
+ fireEvent.click(field());
1381
+ expect(title()).toHaveAttribute('aria-expanded', 'false');
1382
+ expect(panel('calendar')).not.toHaveAttribute('inert');
1383
+ expect(panel('wheels')).toHaveAttribute('inert');
1384
+ });
1385
+
1386
+ it('the year wheel keeps the month', () => {
1387
+ renderAndOpen(<Controlled initial="2026-03-21" {...FIVE_YEARS} />);
1388
+ openWheels();
1389
+ fireEvent.click(
1390
+ within(screen.getByRole('listbox', {name: 'Year'})).getByText('2025'),
1391
+ );
1392
+ expect(title()).toHaveTextContent('March 2025');
1393
+ });
1394
+
1395
+ it('is a single tab stop driven by the arrow keys', () => {
1396
+ renderAndOpen();
1397
+ openWheels();
1398
+ const months = screen.getByRole('listbox', {name: 'Month'});
1399
+ expect(months).toHaveAttribute('tabindex', '0');
1400
+ fireEvent.keyDown(months, {key: 'ArrowDown'});
1401
+ expect(title()).toHaveTextContent('April 2026');
1402
+ fireEvent.keyDown(months, {key: 'ArrowUp'});
1403
+ expect(title()).toHaveTextContent('March 2026');
1404
+ });
1405
+
1406
+ it('will not commit a row outside min/max', () => {
1407
+ renderAndOpen(<Controlled initial="2026-03-10" />);
1408
+ openWheels();
1409
+ const december = within(screen.getByRole('listbox', {name: 'Month'}))
1410
+ .getByText('December')
1411
+ .closest('[role="option"]')!;
1412
+ expect(december).toHaveAttribute('aria-disabled', 'true');
1413
+ fireEvent.click(december);
1414
+ expect(title()).toHaveTextContent('March 2026');
1415
+ });
1416
+
1417
+ it('bounds the year wheel to the reachable range', () => {
1418
+ renderAndOpen(
1419
+ <Controlled initial="2026-03-10" min="2025-01-01" max="2027-12-31" />,
1420
+ );
1421
+ openWheels();
1422
+ expect(
1423
+ within(screen.getByRole('listbox', {name: 'Year'}))
1424
+ .getAllByRole('option')
1425
+ .map(o => o.textContent),
1426
+ ).toEqual(['2025', '2026', '2027']);
1427
+ });
1428
+
1429
+ it('the title is what closes them again', () => {
1430
+ renderAndOpen();
1431
+ openWheels();
1432
+ expect(title()).toHaveAttribute('aria-expanded', 'true');
1433
+ fireEvent.click(title());
1434
+ expect(title()).toHaveAttribute('aria-expanded', 'false');
1435
+ expect(panel('wheels')).toHaveAttribute('inert');
1436
+ });
1437
+
1438
+ it('Reset empties the field and brings the calendar home', () => {
1439
+ const onChange = vi.fn();
1440
+ // Today is 15 March 2026 in these tests; open on a month away from it.
1441
+ renderAndOpen(
1442
+ <Controlled
1443
+ initial="2026-08-21"
1444
+ onChange={onChange}
1445
+ min="2026-01-01"
1446
+ max="2026-12-31"
1447
+ />,
1448
+ );
1449
+ expect(title()).toHaveTextContent('August 2026');
1450
+
1451
+ fireEvent.click(screen.getByRole('button', {name: 'Reset'}));
1452
+ expect(onChange).toHaveBeenCalledWith(undefined);
1453
+ expect(field()).toHaveValue('');
1454
+ // Clearing the date and leaving the calendar on the month of the date you
1455
+ // just cleared is a half-finished action.
1456
+ expect(title()).toHaveTextContent('March 2026');
1457
+ // And it does not dismiss — the sheet is still there to pick again.
1458
+ expect(field()).toHaveAttribute('aria-expanded', 'true');
1459
+ });
1460
+
1461
+ /**
1462
+ * "If possible" is the whole subtlety. A range can exclude the current
1463
+ * month entirely — a booking window that opens next quarter — and there is
1464
+ * then no honest month to go to. Clamping would land on the nearest edge
1465
+ * and present a different month as though it were today's, so the move is
1466
+ * skipped and the calendar stays put. The value clears either way.
1467
+ */
1468
+ it('clears without moving when the current month is out of range', () => {
1469
+ const onChange = vi.fn();
1470
+ renderAndOpen(
1471
+ <Controlled
1472
+ initial="2027-05-10"
1473
+ onChange={onChange}
1474
+ min="2027-01-01"
1475
+ max="2027-12-31"
1476
+ />,
1477
+ );
1478
+ expect(title()).toHaveTextContent('May 2027');
1479
+
1480
+ fireEvent.click(screen.getByRole('button', {name: 'Reset'}));
1481
+ expect(onChange).toHaveBeenCalledWith(undefined);
1482
+ expect(field()).toHaveValue('');
1483
+ expect(title()).toHaveTextContent('May 2027');
1484
+ });
1485
+
1486
+ it('offers Reset only on the calendar, beside Save', () => {
1487
+ renderAndOpen();
1488
+ expect(screen.getByRole('button', {name: 'Reset'})).toBeInTheDocument();
1489
+ openWheels();
1490
+ // The wheels choose a month; there is no date there to clear.
1491
+ expect(screen.queryByRole('button', {name: 'Reset'})).toBeNull();
1492
+ });
1493
+
1494
+ it('Save closes the whole picker; Done only leaves the wheels', () => {
1495
+ // Two finishes, deliberately different: Save ends the task, Done ends a
1496
+ // step. They never appear together — each belongs to the surface it is
1497
+ // shown on — so neither has to carry two meanings by position.
1498
+ renderAndOpen();
1499
+ openWheels();
1500
+ fireEvent.click(screen.getByRole('button', {name: 'Done'}));
1501
+ // Back on the calendar, still open.
1502
+ expect(field()).toHaveAttribute('aria-expanded', 'true');
1503
+ expect(title()).toHaveAttribute('aria-expanded', 'false');
1504
+
1505
+ fireEvent.click(screen.getByRole('button', {name: 'Save'}));
1506
+ expect(field()).toHaveAttribute('aria-expanded', 'false');
1507
+ });
1508
+
1509
+ /**
1510
+ * The two actions share one grid cell and take turns, so the footer is one
1511
+ * button tall whichever is showing and the sheet never changes height
1512
+ * mid-swap. `inert` is what keeps the hidden one out of the accessibility
1513
+ * tree, rather than merely out of sight.
1514
+ */
1515
+ /**
1516
+ * `inert` disables everything INSIDE it, so an inert ancestor is enough to
1517
+ * kill a button that looks perfectly fine on its own.
1518
+ *
1519
+ * This is not hypothetical. The footer kept an `inert` from an earlier
1520
+ * version where it was hidden wholesale on the wheels; once the wheels grew
1521
+ * their own Done button inside that same footer, the button rendered,
1522
+ * looked right, passed every attribute assertion — and did nothing when
1523
+ * tapped.
1524
+ *
1525
+ * Nothing above caught it: `inert` has no behavioural effect in jsdom, so
1526
+ * role queries still found the button, and the cell's own attribute was
1527
+ * correct. The only honest check is to walk the ancestors, which is what
1528
+ * this does.
1529
+ */
1530
+ it('leaves no inert ancestor over whichever action is showing', () => {
1531
+ const inertAncestorsOf = (label: string) => {
1532
+ const el = [...document.querySelectorAll('dialog[open] button')].find(
1533
+ b => b.textContent?.trim() === label,
1534
+ )!;
1535
+ const blocking: string[] = [];
1536
+ // Start above the button's own cell, which is legitimately inert for
1537
+ // the action that is currently hidden.
1538
+ let node = el.parentElement?.parentElement ?? null;
1539
+ while (node != null && node.tagName !== 'BODY') {
1540
+ if (node.hasAttribute('inert')) {
1541
+ blocking.push(node.tagName.toLowerCase());
1542
+ }
1543
+ node = node.parentElement;
1544
+ }
1545
+ return blocking;
1546
+ };
1547
+
1548
+ renderAndOpen();
1549
+ expect(inertAncestorsOf('Save')).toEqual([]);
1550
+ expect(inertAncestorsOf('Reset')).toEqual([]);
1551
+
1552
+ openWheels();
1553
+ expect(inertAncestorsOf('Done')).toEqual([]);
1554
+ });
1555
+
1556
+ it('shows exactly one footer action, and only the visible one is reachable', () => {
1557
+ renderAndOpen();
1558
+ // Queried off the DOM rather than by role: every role-with-name query
1559
+ // walks this tree computing accessible names, and there are ~150
1560
+ // elements in it. Same reason the title helper above does it this way.
1561
+ const button = (label: string) =>
1562
+ [...document.querySelectorAll('dialog[open] button')].find(
1563
+ el => el.textContent?.trim() === label,
1564
+ );
1565
+ // Which ANCESTOR carries `inert`, not which parent — the wheels' action
1566
+ // sits inside a fading wrapper, and asserting on `parentElement` would
1567
+ // pass or fail on that nesting rather than on reachability.
1568
+ const isBlocked = (label: string) =>
1569
+ button(label)?.closest('[inert]') != null;
1570
+
1571
+ expect(isBlocked('Save')).toBe(false);
1572
+ expect(isBlocked('Done')).toBe(true);
1573
+ expect(screen.queryByRole('button', {name: 'Done'})).toBeNull();
1574
+
1575
+ openWheels();
1576
+ expect(isBlocked('Save')).toBe(true);
1577
+ expect(isBlocked('Done')).toBe(false);
1578
+ expect(screen.queryByRole('button', {name: 'Save'})).toBeNull();
1579
+
1580
+ // Both stay mounted throughout, which is what keeps the row's height
1581
+ // fixed across the swap.
1582
+ expect(button('Save')).toBeInTheDocument();
1583
+ expect(button('Done')).toBeInTheDocument();
1584
+ });
1585
+
1586
+ it('the year wheel keeps the month', () => {
1587
+ renderAndOpen(<Controlled initial="2026-03-21" {...FIVE_YEARS} />);
1588
+ openWheels();
1589
+ fireEvent.click(
1590
+ within(screen.getByRole('listbox', {name: 'Year'})).getByText('2025'),
1591
+ );
1592
+ expect(title()).toHaveTextContent('March 2025');
1593
+ });
1594
+
1595
+ it('is a single tab stop driven by the arrow keys', () => {
1596
+ renderAndOpen();
1597
+ openWheels();
1598
+ const months = screen.getByRole('listbox', {name: 'Month'});
1599
+ expect(months).toHaveAttribute('tabindex', '0');
1600
+ fireEvent.keyDown(months, {key: 'ArrowDown'});
1601
+ expect(title()).toHaveTextContent('April 2026');
1602
+ fireEvent.keyDown(months, {key: 'ArrowUp'});
1603
+ expect(title()).toHaveTextContent('March 2026');
1604
+ });
1605
+
1606
+ it('will not commit a row outside min/max', () => {
1607
+ renderAndOpen(<Controlled initial="2026-03-10" />);
1608
+ openWheels();
1609
+ const december = within(screen.getByRole('listbox', {name: 'Month'}))
1610
+ .getByText('December')
1611
+ .closest('[role="option"]')!;
1612
+ expect(december).toHaveAttribute('aria-disabled', 'true');
1613
+ fireEvent.click(december);
1614
+ expect(title()).toHaveTextContent('March 2026');
1615
+ });
1616
+
1617
+ it('bounds the year wheel to the reachable range', () => {
1618
+ renderAndOpen(
1619
+ <Controlled initial="2026-03-10" min="2025-01-01" max="2027-12-31" />,
1620
+ );
1621
+ openWheels();
1622
+ expect(
1623
+ within(screen.getByRole('listbox', {name: 'Year'}))
1624
+ .getAllByRole('option')
1625
+ .map(o => o.textContent),
1626
+ ).toEqual(['2025', '2026', '2027']);
1627
+ });
1628
+
1629
+ it('the title is what closes them again', () => {
1630
+ renderAndOpen();
1631
+ openWheels();
1632
+ expect(title()).toHaveAttribute('aria-expanded', 'true');
1633
+ fireEvent.click(title());
1634
+ expect(title()).toHaveAttribute('aria-expanded', 'false');
1635
+ expect(panel('wheels')).toHaveAttribute('inert');
1636
+ });
1637
+ });
1638
+
1639
+ // ---------------------------------------------------------------------------
1640
+ // Gesture ownership — the nested scrollers vs. the sheet's swipe-to-dismiss
1641
+ // ---------------------------------------------------------------------------
1642
+
1643
+ describe('DateInput — nested scrollers keep their own touch gesture', () => {
1644
+ /**
1645
+ * Stand-in for `BottomSheet`'s swipe-to-dismiss listener: a NATIVE listener
1646
+ * on an ancestor, in the bubble phase, which is exactly how the sheet
1647
+ * attaches its own (`addEventListener('touchstart', …, {passive: true})` on
1648
+ * its scrolling body). If an event reaches this, the sheet would have read
1649
+ * it as a drag.
1650
+ */
1651
+ function watchAncestor(): {seen: string[]; stop: () => void} {
1652
+ const seen: string[] = [];
1653
+ const listener = (event: Event) => seen.push(event.type);
1654
+ for (const type of ['touchstart', 'touchmove', 'touchend']) {
1655
+ document.body.addEventListener(type, listener);
1656
+ }
1657
+ return {
1658
+ seen,
1659
+ stop: () => {
1660
+ for (const type of ['touchstart', 'touchmove', 'touchend']) {
1661
+ document.body.removeEventListener(type, listener);
1662
+ }
1663
+ },
1664
+ };
1665
+ }
1666
+
1667
+ /**
1668
+ * A touch event realistic enough for a real listener to read.
1669
+ *
1670
+ * jsdom has no constructible `TouchEvent`, and a bare `Event` carries no
1671
+ * `changedTouches` — which crashes any handler that reads one, including
1672
+ * BottomSheet's (`event.changedTouches[0]`). That crash is an artifact of
1673
+ * the fake event, not a bug: a browser's touchstart always has the list.
1674
+ * The stand-in below carries it, so an event that DOES reach the sheet
1675
+ * exercises the sheet rather than blowing up inside it.
1676
+ */
1677
+ const touch = (
1678
+ el: Element,
1679
+ type: string,
1680
+ at: {x: number; y: number} = {x: 100, y: 200},
1681
+ ) => {
1682
+ const event = new Event(type, {
1683
+ bubbles: true,
1684
+ cancelable: type !== 'touchend',
1685
+ });
1686
+ const point = {identifier: 1, clientX: at.x, clientY: at.y, target: el};
1687
+ Object.defineProperties(event, {
1688
+ changedTouches: {value: [point]},
1689
+ touches: {value: type === 'touchend' ? [] : [point]},
1690
+ targetTouches: {value: type === 'touchend' ? [] : [point]},
1691
+ });
1692
+ return el.dispatchEvent(event);
1693
+ };
1694
+
1695
+ /**
1696
+ * A whole gesture: down at the origin, drag by (dx, dy), lift. Returns the
1697
+ * scroller's stubbed `scrollBy`, which is how the paging fallback shows up
1698
+ * (jsdom implements neither scrolling nor `scrollBy`).
1699
+ */
1700
+ const swipe = (
1701
+ el: Element,
1702
+ dx: number,
1703
+ dy: number,
1704
+ {scrollsBy = 0}: {scrollsBy?: number} = {},
1705
+ ) => {
1706
+ // jsdom has no layout, so `scrollLeft` never moves on its own. Shadow it
1707
+ // on this instance for the gesture: a native pan is the browser moving
1708
+ // the scroller mid-drag, and watching for exactly that is how the hook
1709
+ // knows whether its claim was honoured. Instance-only and deleted after,
1710
+ // per the prototype-getter cost noted at the top of this file.
1711
+ let offset = 0;
1712
+ const scrollBy = vi.fn();
1713
+ Object.defineProperties(el, {
1714
+ scrollLeft: {
1715
+ configurable: true,
1716
+ get: () => offset,
1717
+ set: (value: number) => {
1718
+ offset = value;
1719
+ },
1720
+ },
1721
+ scrollBy: {configurable: true, value: scrollBy},
1722
+ });
1723
+ const origin = {x: 150, y: 200};
1724
+ touch(el, 'touchstart', origin);
1725
+ for (const step of [0.5, 1]) {
1726
+ if (step === 0.5) {
1727
+ offset += scrollsBy;
1728
+ }
1729
+ touch(el, 'touchmove', {
1730
+ x: origin.x + dx * step,
1731
+ y: origin.y + dy * step,
1732
+ });
1733
+ }
1734
+ touch(el, 'touchend');
1735
+ // @ts-expect-error - removing the shadows restores the prototype's
1736
+ delete el.scrollLeft;
1737
+ // @ts-expect-error - same
1738
+ delete el.scrollBy;
1739
+ return scrollBy;
1740
+ };
1741
+
1742
+ it('lets a touch on the calendar reach the sheet, now that it pages sideways', () => {
1743
+ renderAndOpen();
1744
+ const scroller = document.querySelector('[data-scroller="months"]')!;
1745
+ const ancestor = watchAncestor();
1746
+ touch(scroller, 'touchstart');
1747
+ touch(scroller, 'touchmove');
1748
+ // The calendar used to claim the gesture, because it scrolled vertically
1749
+ // and the sheet read every downward drag as a dismiss. Paging sideways
1750
+ // removes the conflict: horizontal pans stay here and vertical ones go to
1751
+ // the sheet, so a downward drag can go back to meaning swipe-to-dismiss.
1752
+ // A move with no direction yet (the same point twice) is nobody's.
1753
+ expect(ancestor.seen).toEqual(['touchstart', 'touchmove']);
1754
+ ancestor.stop();
1755
+ });
1756
+
1757
+ it('claims a horizontal drag on the calendar', () => {
1758
+ renderAndOpen();
1759
+ const scroller = document.querySelector('[data-scroller="months"]')!;
1760
+ const ancestor = watchAncestor();
1761
+ swipe(scroller, -80, 0);
1762
+ // touchstart still propagates — 'inline' cannot know the direction yet —
1763
+ // but every move after the axis locks is ours.
1764
+ expect(ancestor.seen).not.toContain('touchmove');
1765
+ ancestor.stop();
1766
+ });
1767
+
1768
+ it('leaves a downward drag on the calendar to the sheet', () => {
1769
+ renderAndOpen();
1770
+ const scroller = document.querySelector('[data-scroller="months"]')!;
1771
+ const ancestor = watchAncestor();
1772
+ swipe(scroller, 0, 80);
1773
+ expect(ancestor.seen).toContain('touchmove');
1774
+ ancestor.stop();
1775
+ });
1776
+
1777
+ it('keeps a diagonal drag, because a thumb arcs as it swipes', () => {
1778
+ renderAndOpen();
1779
+ const scroller = document.querySelector('[data-scroller="months"]')!;
1780
+ const ancestor = watchAncestor();
1781
+ // ~50° off horizontal: past the browser's own pan-x cone, still ours.
1782
+ swipe(scroller, -60, 72);
1783
+ expect(ancestor.seen).not.toContain('touchmove');
1784
+ ancestor.stop();
1785
+ });
1786
+
1787
+ /**
1788
+ * The band between our claim and the browser's `pan-x` cone. The sheet has
1789
+ * been told to keep off, and the compositor refuses to pan, so without the
1790
+ * fallback these gestures would do nothing at all — measured as a dead zone
1791
+ * from 45° to 60° on an iPhone 15 profile.
1792
+ */
1793
+ it('pages a month itself when the browser refuses to pan a claimed swipe', () => {
1794
+ renderAndOpen();
1795
+ const scroller = document.querySelector('[data-scroller="months"]')!;
1796
+
1797
+ const forward = swipe(scroller, -60, 72);
1798
+ expect(forward).toHaveBeenCalledTimes(1);
1799
+ expect(forward.mock.calls[0][0]).toMatchObject({behavior: 'smooth'});
1800
+ // Swiping left advances: the offset moves towards the end of the line.
1801
+ expect(forward.mock.calls[0][0].left).toBeGreaterThan(0);
1802
+
1803
+ const back = swipe(scroller, 60, 72);
1804
+ expect(back.mock.calls[0][0].left).toBeLessThan(0);
1805
+ });
1806
+
1807
+ it('stays out of the way when the browser did pan', () => {
1808
+ renderAndOpen();
1809
+ const scroller = document.querySelector('[data-scroller="months"]')!;
1810
+ // Native momentum and snapping own this one; a second nudge would fight
1811
+ // them. `scrollsBy` stands in for the compositor moving the scroller.
1812
+ expect(swipe(scroller, -120, 0, {scrollsBy: 40})).not.toHaveBeenCalled();
1813
+ });
1814
+
1815
+ it('ignores a claimed gesture too short to be a swipe', () => {
1816
+ renderAndOpen();
1817
+ const scroller = document.querySelector('[data-scroller="months"]')!;
1818
+ expect(swipe(scroller, -SWIPE_DISTANCE + 4, 0)).not.toHaveBeenCalled();
1819
+ });
1820
+
1821
+ it('never pages from a drag it gave to the sheet', () => {
1822
+ renderAndOpen();
1823
+ const scroller = document.querySelector('[data-scroller="months"]')!;
1824
+ expect(swipe(scroller, -40, 200)).not.toHaveBeenCalled();
1825
+ });
1826
+
1827
+ it('stops a touch on a wheel from reaching the sheet', () => {
1828
+ renderAndOpen();
1829
+ fireEvent.click(
1830
+ document.querySelector<HTMLElement>('[data-title="month-year"]')!,
1831
+ );
1832
+ const wheel = screen.getByRole('listbox', {name: 'Month'});
1833
+ const ancestor = watchAncestor();
1834
+ touch(wheel, 'touchstart');
1835
+ touch(wheel, 'touchmove');
1836
+ expect(ancestor.seen).not.toContain('touchstart');
1837
+ expect(ancestor.seen).not.toContain('touchmove');
1838
+ ancestor.stop();
1839
+ });
1840
+
1841
+ it('lets touchend through, so the sheet can reset its own bookkeeping', () => {
1842
+ renderAndOpen();
1843
+ const scroller = document.querySelector('[data-scroller="months"]')!;
1844
+ const ancestor = watchAncestor();
1845
+ touch(scroller, 'touchend');
1846
+ expect(ancestor.seen).toContain('touchend');
1847
+ ancestor.stop();
1848
+ });
1849
+
1850
+ it('leaves the rest of the picker to the sheet', () => {
1851
+ renderAndOpen();
1852
+ // The header is not a scroller: a drag there is the sheet's to interpret,
1853
+ // and it is one of the two places a dismiss can still start from.
1854
+ const title = document.querySelector<HTMLElement>(
1855
+ '[data-title="month-year"]',
1856
+ )!;
1857
+ const ancestor = watchAncestor();
1858
+ touch(title, 'touchstart');
1859
+ touch(title, 'touchmove');
1860
+ expect(ancestor.seen).toEqual(['touchstart', 'touchmove']);
1861
+ ancestor.stop();
1862
+ });
1863
+
1864
+ it('does not swallow taps — stopPropagation must not reach click', () => {
1865
+ const onChange = vi.fn();
1866
+ renderAndOpen(<Controlled initial="2026-03-21" onChange={onChange} />);
1867
+ fireEvent.click(
1868
+ within(pane('March 2026')).getByRole('button', {name: /March 25, 2026/}),
1869
+ );
1870
+ expect(onChange).toHaveBeenCalledWith('2026-03-25');
1871
+ });
1872
+ });
1873
+
1874
+ // ---------------------------------------------------------------------------
1875
+ // Dragging a wheel with a mouse
1876
+ // ---------------------------------------------------------------------------
1877
+
1878
+ /**
1879
+ * A wheel is a scroll container, so a finger pans it for free. A mouse gets
1880
+ * nothing: browsers do not drag-scroll an overflow container, so pressing and
1881
+ * pulling on the one control shaped like a thing you spin did nothing at all.
1882
+ *
1883
+ * It matters on desktop specifically because that is where this surface is
1884
+ * reviewed, themed and screenshotted.
1885
+ */
1886
+ describe('DateInput — a mouse can drag a wheel', () => {
1887
+ const title = () =>
1888
+ document.querySelector<HTMLElement>('[data-title="month-year"]')!;
1889
+
1890
+ /** jsdom has no layout: give the rows a height and the wheel a scrollTop. */
1891
+ function withWheelLayout<T>(fn: () => T): T {
1892
+ Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
1893
+ configurable: true,
1894
+ get(this: HTMLElement) {
1895
+ return this.getAttribute('role') === 'option' ? 28 : 0;
1896
+ },
1897
+ });
1898
+ try {
1899
+ return fn();
1900
+ } finally {
1901
+ // @ts-expect-error - deleting the shadow restores Element's own getter
1902
+ delete HTMLElement.prototype.offsetHeight;
1903
+ }
1904
+ }
1905
+
1906
+ const wheel = () => screen.getByRole('listbox', {name: 'Month'});
1907
+
1908
+ const pointer = (
1909
+ type: string,
1910
+ {y = 0, pointerType = 'mouse', button = 0, id = 1} = {},
1911
+ ) => {
1912
+ const event = new Event(type, {bubbles: true, cancelable: true});
1913
+ Object.defineProperties(event, {
1914
+ clientY: {value: y},
1915
+ pointerId: {value: id},
1916
+ pointerType: {value: pointerType},
1917
+ button: {value: button},
1918
+ });
1919
+ return event;
1920
+ };
1921
+
1922
+ /** Stubs jsdom's missing capture API and records scrollTop as it moves. */
1923
+ function trackable(el: HTMLElement) {
1924
+ let top = 0;
1925
+ Object.defineProperty(el, 'scrollTop', {
1926
+ configurable: true,
1927
+ get: () => top,
1928
+ set: (v: number) => {
1929
+ top = v;
1930
+ },
1931
+ });
1932
+ el.setPointerCapture = () => {};
1933
+ el.releasePointerCapture = () => {};
1934
+ el.hasPointerCapture = () => true;
1935
+ el.scrollTo = ((opts: ScrollToOptions) => {
1936
+ top = opts.top ?? top;
1937
+ }) as HTMLElement['scrollTo'];
1938
+ return {
1939
+ get scrollTop() {
1940
+ return top;
1941
+ },
1942
+ };
1943
+ }
1944
+
1945
+ it('scrolls the wheel when a mouse drags it', () => {
1946
+ renderAndOpen();
1947
+ fireEvent.click(title());
1948
+ withWheelLayout(() => {
1949
+ const el = wheel();
1950
+ const track = trackable(el);
1951
+ el.dispatchEvent(pointer('pointerdown', {y: 200}));
1952
+ el.dispatchEvent(pointer('pointermove', {y: 160}));
1953
+ // Content follows the hand: pulling up scrolls further down the list.
1954
+ expect(track.scrollTop).toBe(40);
1955
+ el.dispatchEvent(pointer('pointerup', {y: 160}));
1956
+ // And the release lands on a row boundary rather than between two.
1957
+ expect(track.scrollTop % 28).toBe(0);
1958
+ });
1959
+ });
1960
+
1961
+ it('ignores movement too small to be a drag, so a click is still a click', () => {
1962
+ renderAndOpen();
1963
+ fireEvent.click(title());
1964
+ withWheelLayout(() => {
1965
+ const el = wheel();
1966
+ const track = trackable(el);
1967
+ el.dispatchEvent(pointer('pointerdown', {y: 200}));
1968
+ el.dispatchEvent(pointer('pointermove', {y: 200 - (DRAG_SLOP - 1)}));
1969
+ expect(track.scrollTop).toBe(0);
1970
+ });
1971
+ });
1972
+
1973
+ it('leaves touch and pen alone — they pan natively, and better', () => {
1974
+ renderAndOpen();
1975
+ fireEvent.click(title());
1976
+ withWheelLayout(() => {
1977
+ for (const pointerType of ['touch', 'pen']) {
1978
+ const el = wheel();
1979
+ const track = trackable(el);
1980
+ el.dispatchEvent(pointer('pointerdown', {y: 200, pointerType}));
1981
+ el.dispatchEvent(pointer('pointermove', {y: 120, pointerType}));
1982
+ expect(track.scrollTop).toBe(0);
1983
+ el.dispatchEvent(pointer('pointerup', {y: 120, pointerType}));
1984
+ }
1985
+ });
1986
+ });
1987
+
1988
+ it('ignores a secondary button, which belongs to the context menu', () => {
1989
+ renderAndOpen();
1990
+ fireEvent.click(title());
1991
+ withWheelLayout(() => {
1992
+ const el = wheel();
1993
+ const track = trackable(el);
1994
+ el.dispatchEvent(pointer('pointerdown', {y: 200, button: 2}));
1995
+ el.dispatchEvent(pointer('pointermove', {y: 120}));
1996
+ expect(track.scrollTop).toBe(0);
1997
+ });
1998
+ });
1999
+
2000
+ /**
2001
+ * `scroll-snap-type: y mandatory` re-snaps after every scroll, programmatic
2002
+ * ones included. Measured with it left on: 7 of 8 five-pixel drag steps were
2003
+ * yanked back to a snap position, so the wheel stuck to a row and then
2004
+ * jumped a whole one.
2005
+ */
2006
+ it('suspends snapping for the drag, and restores it after', () => {
2007
+ renderAndOpen();
2008
+ fireEvent.click(title());
2009
+ withWheelLayout(() => {
2010
+ const el = wheel();
2011
+ trackable(el);
2012
+ expect(el.style.scrollSnapType).toBe('');
2013
+ el.dispatchEvent(pointer('pointerdown', {y: 200}));
2014
+ el.dispatchEvent(pointer('pointermove', {y: 160}));
2015
+ expect(el.style.scrollSnapType).toBe('none');
2016
+ el.dispatchEvent(pointer('pointerup', {y: 160}));
2017
+ el.dispatchEvent(new Event('scrollend'));
2018
+ expect(el.style.scrollSnapType).toBe('');
2019
+ });
2020
+ });
2021
+
2022
+ /**
2023
+ * BottomSheet starts its own drag-to-dismiss from a `pointerdown` on its
2024
+ * body and CAPTURES the pointer for it, which retargets every later pointer
2025
+ * event — including the click. Measured before this: a click on a wheel row
2026
+ * that wobbled more than a pixel selected nothing at all.
2027
+ */
2028
+ it('keeps the press away from the sheet, which would capture the pointer', () => {
2029
+ renderAndOpen();
2030
+ fireEvent.click(title());
2031
+ withWheelLayout(() => {
2032
+ const el = wheel();
2033
+ trackable(el);
2034
+ const seen: string[] = [];
2035
+ const listener = (event: Event) => seen.push(event.type);
2036
+ document.body.addEventListener('pointerdown', listener);
2037
+ el.dispatchEvent(pointer('pointerdown', {y: 200}));
2038
+ expect(seen).not.toContain('pointerdown');
2039
+ document.body.removeEventListener('pointerdown', listener);
2040
+ });
2041
+ });
2042
+ });
2043
+
2044
+ // ---------------------------------------------------------------------------
2045
+ // Pure month arithmetic
2046
+ // ---------------------------------------------------------------------------
2047
+
2048
+ describe('monthGeometry', () => {
2049
+ it('round-trips a month through its index', () => {
2050
+ for (const [year, month] of [
2051
+ [2026, 1],
2052
+ [2026, 12],
2053
+ [1976, 7],
2054
+ [2100, 2],
2055
+ ] as const) {
2056
+ expect(fromMonthIndex(toMonthIndex(year, month))).toEqual({year, month});
2057
+ }
2058
+ });
2059
+
2060
+ it('orders months across a year boundary', () => {
2061
+ expect(toMonthIndex(2027, 1) - toMonthIndex(2026, 12)).toBe(1);
2062
+ });
2063
+
2064
+ it('reads a month index off a PlainDate, ignoring the day', () => {
2065
+ expect(monthIndexOf({year: 2026, month: 3, day: 1})).toBe(
2066
+ monthIndexOf({year: 2026, month: 3, day: 31}),
2067
+ );
2068
+ });
2069
+
2070
+ it('clamps to the reachable range', () => {
2071
+ expect(clampIndex(5, 10, 20)).toBe(10);
2072
+ expect(clampIndex(25, 10, 20)).toBe(20);
2073
+ expect(clampIndex(15, 10, 20)).toBe(15);
2074
+ });
2075
+
2076
+ describe('rowAtScrollOffset', () => {
2077
+ it('maps an exact offset to its row', () => {
2078
+ expect(rowAtScrollOffset(0, PANE, 100)).toBe(0);
2079
+ expect(rowAtScrollOffset(PANE * 7, PANE, 100)).toBe(7);
2080
+ });
2081
+
2082
+ it('rounds to the nearest row mid-scroll', () => {
2083
+ expect(rowAtScrollOffset(PANE * 7 + 10, PANE, 100)).toBe(7);
2084
+ expect(rowAtScrollOffset(PANE * 7 - 10, PANE, 100)).toBe(7);
2085
+ expect(rowAtScrollOffset(PANE * 6.6, PANE, 100)).toBe(7);
2086
+ });
2087
+
2088
+ it('never leaves the list', () => {
2089
+ expect(rowAtScrollOffset(-500, PANE, 100)).toBe(0);
2090
+ expect(rowAtScrollOffset(PANE * 1000, PANE, 100)).toBe(99);
2091
+ });
2092
+
2093
+ it('is 0 before the pane size is known, rather than dividing by zero', () => {
2094
+ expect(rowAtScrollOffset(1234, 0, 100)).toBe(0);
2095
+ });
2096
+
2097
+ it('reads RTL scrollLeft, which counts down from zero', () => {
2098
+ // The spec puts the inline start at 0 and runs negative from there, so
2099
+ // an unsigned read would pin an RTL calendar to month zero forever.
2100
+ expect(rowAtScrollOffset(-PANE * 7, PANE, 100, true)).toBe(7);
2101
+ expect(rowAtScrollOffset(-PANE * 7 - 10, PANE, 100, true)).toBe(7);
2102
+ expect(rowAtScrollOffset(0, PANE, 100, true)).toBe(0);
2103
+ });
2104
+ });
2105
+
2106
+ describe('scrollOffsetForRow', () => {
2107
+ it('is the inverse of rowAtScrollOffset, in both directions', () => {
2108
+ for (const isRTL of [false, true]) {
2109
+ for (const row of [0, 1, 7, 99]) {
2110
+ const offset = scrollOffsetForRow(row, PANE, isRTL);
2111
+ expect(rowAtScrollOffset(offset, PANE, 100, isRTL)).toBe(row);
2112
+ }
2113
+ }
2114
+ });
2115
+
2116
+ it('runs negative under RTL', () => {
2117
+ expect(scrollOffsetForRow(3, PANE, false)).toBe(PANE * 3);
2118
+ expect(scrollOffsetForRow(3, PANE, true)).toBe(-PANE * 3);
2119
+ });
2120
+ });
2121
+
2122
+ describe('paneWindow', () => {
2123
+ it('mounts the overscan on both sides', () => {
2124
+ expect(paneWindow(50, 100, 3)).toEqual({start: 47, end: 53});
2125
+ expect(rowsIn(paneWindow(50, 100, 1))).toEqual([49, 50, 51]);
2126
+ });
2127
+
2128
+ it('truncates at the ends of the list instead of going out of bounds', () => {
2129
+ expect(paneWindow(0, 100, 3)).toEqual({start: 0, end: 3});
2130
+ expect(paneWindow(99, 100, 3)).toEqual({start: 96, end: 99});
2131
+ });
2132
+ });
2133
+
2134
+ it('reaches a century in each direction by default', () => {
2135
+ expect(DEFAULT_MONTH_REACH).toBe(600);
2136
+ });
2137
+ });
2138
+
2139
+ // ---------------------------------------------------------------------------
2140
+ // Styles jsdom cannot resolve — assert the definition, not the effect
2141
+ // ---------------------------------------------------------------------------
2142
+
2143
+ describe('DateInput — scroll CSS (definition-level)', () => {
2144
+ const dir = path.dirname(fileURLToPath(import.meta.url));
2145
+ const read = (file: string) => readFileSync(path.join(dir, file), 'utf8');
2146
+
2147
+ /**
2148
+ * The declarations inside one named style object, comments stripped and
2149
+ * trailing commas removed.
2150
+ *
2151
+ * Crude on purpose — a nested value object contributes its own inner lines
2152
+ * too. Every caller looks for a specific property prefix, so the noise is
2153
+ * harmless, and a real parser here would be more machinery than the
2154
+ * question deserves.
2155
+ */
2156
+ const declarations = (source: string, object: string) => {
2157
+ const open = source.indexOf(` ${object}: {`);
2158
+ expect(open).toBeGreaterThan(-1);
2159
+ return source
2160
+ .slice(open, source.indexOf('\n },', open))
2161
+ .replace(/\/\/.*$/gm, '')
2162
+ .split('\n')
2163
+ .map(line => line.trim())
2164
+ .filter(line => line.endsWith(','))
2165
+ .map(line => line.slice(0, -1));
2166
+ };
2167
+
2168
+ /** The same, scoped to one `export const <group> = stylex.create({...})`. */
2169
+ const declarationsIn = (source: string, group: string, object: string) => {
2170
+ const at = source.indexOf(`export const ${group}`);
2171
+ expect(at).toBeGreaterThan(-1);
2172
+ return declarations(source.slice(at), object);
2173
+ };
2174
+
2175
+ it('pages the month scroller one whole pane at a time, horizontally', () => {
2176
+ const source = read('MonthScroller.tsx');
2177
+ expect(source).toContain("scrollSnapType: 'x mandatory'");
2178
+ expect(source).toContain("scrollSnapAlign: 'start'");
2179
+ // pan-x is what splits the gesture by axis: horizontal pans stay with the
2180
+ // calendar, vertical ones reach the sheet as swipe-to-dismiss. Without it
2181
+ // the two would fight again, the way they did when this scrolled down.
2182
+ expect(source).toContain("touchAction: 'pan-x'");
2183
+ // The pane and the scrollport must come from the same expression, or a
2184
+ // pane stops being exactly one screen and every snap offset drifts.
2185
+ expect(
2186
+ source.match(/blockSize: dateInputTouchGeometry\.paneBlockSize/g),
2187
+ ).toHaveLength(2);
2188
+ });
2189
+
2190
+ it('positions panes with logical properties, so RTL mirrors', () => {
2191
+ const source = read('MonthScroller.tsx');
2192
+ // Scoped to the style objects: `scrollTo({left})` is the DOM API and is
2193
+ // supposed to say left — it is the CSS that must stay logical, because
2194
+ // physical `left` would lay the months out identically in both directions
2195
+ // while the scroll math mirrored, and the two would disagree under RTL.
2196
+ const styles = source
2197
+ .slice(
2198
+ source.indexOf('const styles = stylex.create('),
2199
+ source.indexOf('export interface MonthScrollerHandle'),
2200
+ )
2201
+ // Comments explain the rule ("insetInlineStart, not left") and would
2202
+ // otherwise trip it.
2203
+ .replace(/\/\/.*$/gm, '');
2204
+ expect(styles).toContain('insetInlineStart');
2205
+ expect(styles).not.toMatch(/\bleft:/);
2206
+ expect(styles).not.toMatch(/\bright:/);
2207
+ });
2208
+
2209
+ /**
2210
+ * Adjacent days take the desktop calendar's exact treatment, and the point
2211
+ * of reading BOTH files is that "exact" stays true if the desktop's changes.
2212
+ *
2213
+ * Calendar splits the treatment across two style objects — `dayCellTheme`
2214
+ * carries the colour, `dayCellStyles` the opacity — so copying it by eye
2215
+ * gets you one half and not the other. This pane took only the colour at
2216
+ * first, and the spill days came out visibly heavier than the desktop's.
2217
+ *
2218
+ * Both surfaces now render the same three tiers, measured in a browser on
2219
+ * the same story: 23 for a day you can pick, 185 for one the range rules
2220
+ * out, 203 for a spilled day (which is outside AND therefore disabled, so
2221
+ * it takes both). The enabled-adjacent tier that used to sit at 169 is gone
2222
+ * with the tap that produced it.
2223
+ */
2224
+ it('mutes adjacent days exactly as the desktop calendar does', () => {
2225
+ // The desktop's two halves, read out of its own source rather than
2226
+ // restated here — so the check follows the desktop if its treatment
2227
+ // moves, instead of freezing today's values into this file.
2228
+ const desktop = read('../Calendar/styles.ts');
2229
+ const structural = declarationsIn(desktop, 'dayCellStyles', 'dayOutside');
2230
+ const theme = declarationsIn(desktop, 'dayCellTheme', 'dayOutside');
2231
+ // Both halves must have found something, or the parity check below is
2232
+ // `arrayContaining([])` and passes on anything at all.
2233
+ expect(structural.length).toBeGreaterThan(0);
2234
+ expect(theme.length).toBeGreaterThan(0);
2235
+
2236
+ // And the touch pane carries all of it, in its one object.
2237
+ const touch = declarations(read('MonthScroller.tsx'), 'dayOutside');
2238
+ expect(touch).toEqual(expect.arrayContaining([...structural, ...theme]));
2239
+ });
2240
+
2241
+ /**
2242
+ * Disabled days follow the desktop too, and this is the half that actually
2243
+ * answered "the disabled dates and the adjacent ones look the same".
2244
+ *
2245
+ * The desktop FADES a disabled day rather than recolouring it: `opacity:
2246
+ * 0.3` over whatever colour the day already had. This pane used to paint a
2247
+ * flat `--color-text-disabled` instead, which on white put a disabled
2248
+ * in-month day at ~163 and an ENABLED adjacent day at ~168 — five levels
2249
+ * apart, indistinguishable, and with the disabled one the darker of the
2250
+ * two, so the unpickable date read as the more solid one. Fading instead
2251
+ * puts every disabled day lighter than every enabled one, which is the
2252
+ * ordering a calendar needs.
2253
+ */
2254
+ it('fades disabled days as the desktop does, rather than recolouring them', () => {
2255
+ const desktop = read('../Calendar/styles.ts');
2256
+ const theme = declarationsIn(desktop, 'dayCellTheme', 'dayDisabled');
2257
+ const touch = declarations(read('MonthScroller.tsx'), 'dayDisabled');
2258
+
2259
+ // Whatever opacity the desktop fades to, this pane fades to the same one.
2260
+ const fade = theme.find(d => d.startsWith('opacity:'));
2261
+ expect(fade).toBeDefined();
2262
+ expect(touch).toContain(fade);
2263
+
2264
+ // And no colour of its own: a colour would override the secondary one a
2265
+ // spilled day carries, collapsing "disabled" and "disabled and adjacent"
2266
+ // into a single shade.
2267
+ expect(touch.some(d => d.startsWith('color:'))).toBe(false);
2268
+ // Parity, not a rule of this pane's own — if the desktop ever starts
2269
+ // recolouring a disabled day, this fires and says to follow it.
2270
+ expect(theme.some(d => d.startsWith('color:'))).toBe(false);
2271
+ });
2272
+
2273
+ /**
2274
+ * Order matters as much as the values: `dayDisabled` is applied AFTER
2275
+ * `dayOutside`, so a spilled day beyond min/max paints disabled rather than
2276
+ * merely outside. Reversed, an unselectable date would look more available
2277
+ * than the selectable ones beside it.
2278
+ */
2279
+ it('lets disabled win over outside on a spilled day past min/max', () => {
2280
+ const applied = read('MonthScroller.tsx');
2281
+ const outside = applied.indexOf('day.isOutside && styles.dayOutside');
2282
+ const disabled = applied.indexOf('isDisabled && styles.dayDisabled');
2283
+ expect(outside).toBeGreaterThan(-1);
2284
+ expect(outside).toBeLessThan(disabled);
2285
+ });
2286
+
2287
+ it('keeps the month scroller and the wheels on border-box', () => {
2288
+ // Load-bearing: clientHeight is the pane height, the snap offsets and the
2289
+ // virtualization all at once.
2290
+ expect(read('MonthScroller.tsx')).toContain("boxSizing: 'border-box'");
2291
+ expect(read('Wheel.tsx')).toContain("boxSizing: 'border-box'");
2292
+ });
2293
+
2294
+ it('sizes the wheel row tighter than a day cell, with larger text', () => {
2295
+ const tokens = read('tokens.stylex.ts');
2296
+ // Scroll-first rows: closer together than day cells and closer to the
2297
+ // text they hold, the way a platform picker packs them.
2298
+ expect(tokens).toContain("wheelItemSize: '28px'");
2299
+ expect(read('Wheel.tsx')).toContain(
2300
+ "fontSize: typeScaleVars['--text-large-size']",
2301
+ );
2302
+ });
2303
+
2304
+ it('centers wheel rows and pads both ends so either extreme can reach the band', () => {
2305
+ const source = read('Wheel.tsx');
2306
+ expect(source).toContain("scrollSnapType: 'y mandatory'");
2307
+ expect(source).toContain("scrollSnapAlign: 'center'");
2308
+ expect(source).toContain(
2309
+ 'paddingBlock: dateInputTouchGeometry.wheelEdgePadding',
2310
+ );
2311
+ });
2312
+
2313
+ it('guards the scroll-driven falloff behind @supports', () => {
2314
+ const source = read('Wheel.tsx');
2315
+ // Without the guard, a browser that does not understand animation-timeline
2316
+ // runs these keyframes once on the document timeline instead.
2317
+ expect(source).toContain("'@supports (animation-timeline: view())'");
2318
+ expect(source).toContain("animationTimeline: 'view(y)'");
2319
+ expect(source).toContain("'@media (prefers-reduced-motion: reduce)'");
2320
+ });
2321
+
2322
+ it('keeps the falloff off the snap area itself', () => {
2323
+ // A transform moves the snap area, and the wheel then settles a few
2324
+ // pixels off the row it is showing.
2325
+ const source = read('Wheel.tsx');
2326
+ const itemInner = source.slice(source.indexOf('itemInner: {'));
2327
+ expect(itemInner).toContain("animationTimeline: 'view(y)'");
2328
+ const item = source.slice(
2329
+ source.indexOf(' item: {'),
2330
+ source.indexOf('itemInner: {'),
2331
+ );
2332
+ expect(item).not.toContain('animationTimeline');
2333
+ expect(item).not.toContain("overflow: 'hidden'");
2334
+ });
2335
+
2336
+ it('insets the sheet content equally on every edge', () => {
2337
+ const source = read('TouchDateField.tsx');
2338
+ // One inset, and the header/footer must not add their own on top of it —
2339
+ // that is what put the title and Done 4px off the day grid's line.
2340
+ expect(source).toContain("paddingInline: spacingVars['--spacing-4']");
2341
+ expect(source).toContain("paddingBlockEnd: spacingVars['--spacing-4']");
2342
+ // The block-start is the documented exception: the grab handle floats out
2343
+ // of flow, so the content wrapper owes it the height it occupies.
2344
+ expect(source).toContain("paddingBlockStart: spacingVars['--spacing-6']");
2345
+ const header = source.slice(
2346
+ source.indexOf(' header: {'),
2347
+ source.indexOf(' title: {'),
2348
+ );
2349
+ expect(header).not.toContain('paddingInline');
2350
+ const footer = source.slice(
2351
+ source.indexOf(' footer: {'),
2352
+ source.indexOf(' sheetBody: {'),
2353
+ );
2354
+ expect(footer).not.toContain('paddingInline');
2355
+ });
2356
+
2357
+ it('floors the touch target without discarding the size prop', () => {
2358
+ const source = read('TouchDateField.tsx');
2359
+ // Each size keeps its own height AND cannot render below a thumb's reach.
2360
+ const sizeMap = source.slice(
2361
+ source.indexOf('const sizeStyles = stylex.create('),
2362
+ source.indexOf('const styles = stylex.create('),
2363
+ );
2364
+ expect(
2365
+ sizeMap.match(
2366
+ /minBlockSize: \{default: null, '@media \(pointer: coarse\)': TOUCH_TARGET\}/g,
2367
+ ),
2368
+ ).toHaveLength(3);
2369
+ });
2370
+
2371
+ it('floors the month arrows too — Button tops out at 36px', () => {
2372
+ const source = read('TouchDateField.tsx');
2373
+ const arrow = source.slice(
2374
+ source.indexOf(' monthArrow: {'),
2375
+ source.indexOf(' monthArrowIcon: {'),
2376
+ );
2377
+ expect(arrow).toContain(
2378
+ "minBlockSize: {default: null, '@media (pointer: coarse)': TOUCH_TARGET}",
2379
+ );
2380
+ expect(arrow).toContain(
2381
+ "minInlineSize: {default: null, '@media (pointer: coarse)': TOUCH_TARGET}",
2382
+ );
2383
+ });
2384
+
2385
+ /**
2386
+ * A bare inline wrapper puts the glyph on the text baseline, a few px above
2387
+ * the button's optical centre. Core's Calendar carries the same rule on its
2388
+ * own nav icons.
2389
+ */
2390
+ it('keeps the mirrored arrow glyph centred', () => {
2391
+ const source = read('TouchDateField.tsx');
2392
+ const icon = source.slice(source.indexOf(' monthArrowIcon: {'));
2393
+ expect(icon.slice(0, icon.indexOf('}'))).toContain(
2394
+ "display: 'inline-flex'",
2395
+ );
2396
+ });
2397
+
2398
+ /**
2399
+ * The wheels are a layer that fades in and out on top; the calendar under
2400
+ * them is covered and uncovered, and never seen to move.
2401
+ *
2402
+ * The swap used to take turns — the outgoing surface faded out over a first
2403
+ * leg, the incoming one waited and faded in over a second — because the
2404
+ * wheels' panel was transparent. Overlapping them put the wheels'
2405
+ * translucent selection band over the live calendar grid and tinted one
2406
+ * band-shaped strip of it: "the grey area animates differently from the
2407
+ * content".
2408
+ *
2409
+ * An opaque plate INSIDE the fading layer fixes that at the root. The layer
2410
+ * renders as one finished opaque image and the fade applies to the image,
2411
+ * so the band crosses at the same rate as everything beside it. And once it
2412
+ * is opaque, the calendar needs no fade of its own — animating it too would
2413
+ * be animating the date picker rather than the thing arriving over it.
2414
+ *
2415
+ * What this pins is that split: the layer above gets opacity and a
2416
+ * background, the layer beneath gets visibility and nothing else.
2417
+ */
2418
+ it('fades the wheels in and out over a calendar that does not animate', () => {
2419
+ const source = read('TouchDateField.tsx');
2420
+ const overlay = declarations(source, 'panelOverlay');
2421
+ const beneath = declarations(source, 'panelBeneath');
2422
+
2423
+ // The layer fades, both directions — the transition is on the shown
2424
+ // state, so entering and leaving it both animate.
2425
+ expect(overlay).toContain("transitionProperty: 'opacity, visibility'");
2426
+ expect(overlay).toContain('transitionDuration: SWAP_DURATION');
2427
+ expect(declarations(source, 'panelOverlayHidden')).toContain('opacity: 0');
2428
+
2429
+ // And it is opaque while it does, in the token the sheet paints itself
2430
+ // with, so the fade is uniform across the band and the text alike.
2431
+ expect(overlay).toContain(
2432
+ "backgroundColor: colorVars['--color-background-surface']",
2433
+ );
2434
+
2435
+ // The layer beneath moves nothing that can be SEEN moving: visibility
2436
+ // only, no opacity, so it is covered and uncovered rather than faded.
2437
+ expect(beneath).toContain("transitionProperty: 'visibility'");
2438
+ expect(beneath.some(d => d.startsWith('opacity'))).toBe(false);
2439
+ expect(
2440
+ declarations(source, 'panelBeneathHidden').some(d =>
2441
+ d.startsWith('opacity'),
2442
+ ),
2443
+ ).toBe(false);
2444
+
2445
+ // Two of each, and never the same one twice: the calendar panel and the
2446
+ // calendar's footer actions are beneath, the wheels and their Done above.
2447
+ const count = (name: string) =>
2448
+ source.match(new RegExp(`styles\\.${name},`, 'g'))?.length ?? 0;
2449
+ expect(count('panelBeneath')).toBe(2);
2450
+ expect(count('panelOverlay')).toBe(2);
2451
+ });
2452
+
2453
+ /**
2454
+ * The layer paints as a unit, which is what makes "opaque" mean "covers".
2455
+ *
2456
+ * Backgrounds and text paint in separate phases, so without a stacking
2457
+ * context a later sibling's background lands UNDER an earlier sibling's
2458
+ * text — the plate went in opaque and the calendar's day numbers showed
2459
+ * straight through it. Measured before and after: ink inside the grid's box
2460
+ * at full cover went from the calendar's 2.5% of pixels to the wheels' own.
2461
+ *
2462
+ * Easy to lose, because any opacity below 1 makes a stacking context by
2463
+ * accident. It only breaks at the two ends of the fade, where opacity is
2464
+ * exactly 1 — which is to say, whenever anyone is actually looking.
2465
+ */
2466
+ it('paints the layer as a unit, so the plate really covers', () => {
2467
+ expect(declarations(read('TouchDateField.tsx'), 'panelOverlay')).toContain(
2468
+ "isolation: 'isolate'",
2469
+ );
2470
+ });
2471
+
2472
+ /**
2473
+ * The weekday row and the header arrows are the one part of the calendar
2474
+ * the layer cannot cover — the plate starts below the header. They fade on
2475
+ * the layer's own timing rather than clearing instantly, so the change
2476
+ * reads as one motion instead of chrome blinking out a beat ahead of the
2477
+ * grid being covered.
2478
+ */
2479
+ it('fades the uncovered chrome on the same timing as the layer', () => {
2480
+ const source = read('TouchDateField.tsx');
2481
+ for (const name of ['weekdays', 'monthArrows']) {
2482
+ expect(declarations(source, name)).toContain(
2483
+ 'transitionDuration: SWAP_DURATION',
2484
+ );
2485
+ expect(declarations(source, `${name}Hidden`)).toContain('opacity: 0');
2486
+ }
2487
+ });
2488
+
2489
+ /**
2490
+ * Both footer actions span the sheet. A full-width primary is the shape a
2491
+ * phone form ends with, and it puts the target under the thumb wherever
2492
+ * the hand is.
2493
+ */
2494
+ it('spans the footer with its actions', () => {
2495
+ const source = read('TouchDateField.tsx');
2496
+ // Reset + Save share the calendar's cell; Done is the wheels'. Each
2497
+ // fills the space it is given, so the row divides evenly rather than by
2498
+ // label length.
2499
+ expect(source.match(/width="100%"/g)).toHaveLength(3);
2500
+ });
2501
+
2502
+ /**
2503
+ * The selection band has to be visible enough that fading it reads as a
2504
+ * fade. At `--color-background-muted` (4.7% alpha) the whole plate sat 17
2505
+ * units of colour from the sheet behind it, so its animation had 17 units
2506
+ * to happen in while the text beside it travelled 412 — it did not look
2507
+ * like it was fading, it looked like it appeared at the end.
2508
+ * `--color-neutral` (10%) doubles that to 36 and is still quiet enough to
2509
+ * sit under text.
2510
+ */
2511
+ it('gives the wheel band enough contrast for its fade to read', () => {
2512
+ const source = read('Wheel.tsx');
2513
+ const band = source.slice(
2514
+ source.indexOf(' band: {'),
2515
+ source.indexOf('});', source.indexOf(' band: {')),
2516
+ );
2517
+ expect(band).toContain("backgroundColor: colorVars['--color-neutral']");
2518
+ });
2519
+
2520
+ /**
2521
+ * `--ease-standard` is `cubic-bezier(0.24, 1, 0.4, 1)`: right for something
2522
+ * travelling a distance, wrong for a fade. Measured with it, opacity hit
2523
+ * 50% in 91ms and 95% in 241ms of a 410ms transition — the fade was over
2524
+ * long before the duration was, so lengthening the duration bought an
2525
+ * imperceptible tail rather than a slower fade. A fade covers no distance,
2526
+ * so its progress should simply be its progress.
2527
+ *
2528
+ * The title chevron is the exception and keeps the token: it rotates, and
2529
+ * rotation is travel.
2530
+ */
2531
+ it('fades linearly, and eases only the thing that travels', () => {
2532
+ const source = read('TouchDateField.tsx');
2533
+ const styles = source.slice(
2534
+ source.indexOf('const styles = stylex.create('),
2535
+ );
2536
+ const chevron = styles.slice(
2537
+ styles.indexOf(' titleChevron: {'),
2538
+ styles.indexOf(' titleChevronOpen: {'),
2539
+ );
2540
+ expect(chevron).toContain("transitionProperty: 'transform'");
2541
+ expect(chevron).toContain(
2542
+ "transitionTimingFunction: easeVars['--ease-standard']",
2543
+ );
2544
+ // The chevron is the only eased transition; every fade is linear.
2545
+ expect(
2546
+ styles.match(/transitionTimingFunction: easeVars\['--ease-standard'\]/g),
2547
+ ).toHaveLength(1);
2548
+ expect(styles.match(/transitionTimingFunction: 'linear'/g)).toHaveLength(3);
2549
+ });
2550
+
2551
+ /**
2552
+ * Everything in the swap runs for the same time, so it reads as one change
2553
+ * rather than several. The chevron included — it used to run
2554
+ * `--duration-medium` against a 220ms swap and was still turning well after
2555
+ * the wheels had settled.
2556
+ *
2557
+ * A token rather than a literal, so a consumer's motion scale carries: the
2558
+ * Storybook theme resolves `--duration-fast` to 125ms, not the default
2559
+ * 175ms, and the swap follows it without knowing.
2560
+ */
2561
+ it('runs the whole swap on one duration', () => {
2562
+ const source = read('TouchDateField.tsx');
2563
+ const styles = source.slice(
2564
+ source.indexOf('const styles = stylex.create('),
2565
+ );
2566
+ // The arrows, the weekday row, the layer beneath, the layer above, and
2567
+ // the chevron.
2568
+ expect(styles.match(/transitionDuration: SWAP_DURATION/g)).toHaveLength(5);
2569
+ // And no leftover hand-rolled timing beside them.
2570
+ expect(styles).not.toContain('PANEL_FADE_MS');
2571
+ expect(source).toContain(
2572
+ "const SWAP_DURATION = durationVars['--duration-fast']",
2573
+ );
2574
+ });
2575
+
2576
+ it('keeps the virtual keyboard down on the touch field', () => {
2577
+ const source = read('TouchDateField.tsx');
2578
+ // readOnly alone still opens the keyboard on some Android browsers.
2579
+ expect(source).toContain('readOnly');
2580
+ expect(source).toContain('inputMode="none"');
2581
+ });
2582
+ });