@astryxdesign/core 0.4.7 → 0.5.0-canary.055e75f

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 (597) hide show
  1. package/CHANGELOG.md +161 -1
  2. package/dist/AlertDialog/AlertDialog.d.ts +1 -1
  3. package/dist/AlertDialog/AlertDialog.d.ts.map +1 -1
  4. package/dist/AlertDialog/AlertDialog.js +45 -20
  5. package/dist/AppShell/AppShell.d.ts.map +1 -1
  6. package/dist/AppShell/AppShell.js +3 -2
  7. package/dist/AspectRatio/AspectRatio.d.ts +24 -0
  8. package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
  9. package/dist/AspectRatio/AspectRatio.js +23 -4
  10. package/dist/Avatar/Avatar.d.ts +25 -1
  11. package/dist/Avatar/Avatar.d.ts.map +1 -1
  12. package/dist/Avatar/Avatar.js +58 -19
  13. package/dist/Avatar/index.d.ts +2 -2
  14. package/dist/Avatar/index.d.ts.map +1 -1
  15. package/dist/Avatar/index.js +1 -1
  16. package/dist/AvatarGroup/AvatarGroup.d.ts +9 -2
  17. package/dist/AvatarGroup/AvatarGroup.d.ts.map +1 -1
  18. package/dist/AvatarGroup/AvatarGroup.js +8 -4
  19. package/dist/AvatarGroup/AvatarGroupContext.d.ts +2 -1
  20. package/dist/AvatarGroup/AvatarGroupContext.d.ts.map +1 -1
  21. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  22. package/dist/AvatarGroup/AvatarGroupOverflow.js +10 -6
  23. package/dist/Banner/Banner.d.ts +40 -15
  24. package/dist/Banner/Banner.d.ts.map +1 -1
  25. package/dist/Banner/Banner.js +51 -18
  26. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  27. package/dist/BottomSheet/BottomSheet.js +17 -4
  28. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  29. package/dist/BottomSheet/BottomSheetSwitcher.js +3 -2
  30. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  31. package/dist/Breadcrumbs/BreadcrumbItem.js +11 -10
  32. package/dist/Button/Button.d.ts.map +1 -1
  33. package/dist/Button/Button.js +11 -6
  34. package/dist/ButtonGroup/ButtonGroup.d.ts +5 -1
  35. package/dist/ButtonGroup/ButtonGroup.d.ts.map +1 -1
  36. package/dist/ButtonGroup/ButtonGroup.js +26 -7
  37. package/dist/Calendar/Calendar.d.ts +3 -1
  38. package/dist/Calendar/Calendar.d.ts.map +1 -1
  39. package/dist/Calendar/Calendar.js +14 -15
  40. package/dist/Calendar/getInitialFocusDate.d.ts +46 -0
  41. package/dist/Calendar/getInitialFocusDate.d.ts.map +1 -0
  42. package/dist/Calendar/getInitialFocusDate.js +62 -0
  43. package/dist/Calendar/styles.d.ts +5 -14
  44. package/dist/Calendar/styles.d.ts.map +1 -1
  45. package/dist/Calendar/styles.js +15 -15
  46. package/dist/Card/Card.d.ts +6 -9
  47. package/dist/Card/Card.d.ts.map +1 -1
  48. package/dist/Card/Card.js +6 -6
  49. package/dist/Carousel/Carousel.d.ts.map +1 -1
  50. package/dist/Carousel/Carousel.js +3 -3
  51. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  52. package/dist/Chat/ChatComposerInput.js +2 -3
  53. package/dist/Chat/ChatLayout.d.ts.map +1 -1
  54. package/dist/Chat/ChatLayout.js +3 -3
  55. package/dist/Chat/ChatMessageList.d.ts +22 -3
  56. package/dist/Chat/ChatMessageList.d.ts.map +1 -1
  57. package/dist/Chat/ChatMessageList.js +6 -3
  58. package/dist/Chat/ChatTokenizedText.d.ts +2 -0
  59. package/dist/Chat/ChatTokenizedText.d.ts.map +1 -1
  60. package/dist/Chat/ChatTokenizedText.js +11 -6
  61. package/dist/Chat/useChatComposerTokens.js +1 -1
  62. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  63. package/dist/CheckboxInput/CheckboxInput.js +10 -3
  64. package/dist/ClickableCard/ClickableCard.d.ts.map +1 -1
  65. package/dist/ClickableCard/ClickableCard.js +3 -3
  66. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  67. package/dist/CodeBlock/CodeBlock.js +15 -4
  68. package/dist/CodeBlock/highlightRanges.js +1 -1
  69. package/dist/CodeBlock/highlightStyles.js +13 -13
  70. package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
  71. package/dist/CommandPalette/CommandPaletteInput.js +3 -2
  72. package/dist/CommandPalette/CommandPaletteItem.d.ts.map +1 -1
  73. package/dist/CommandPalette/CommandPaletteItem.js +3 -2
  74. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  75. package/dist/ContextMenu/ContextMenu.js +4 -3
  76. package/dist/DateInput/DateInput.d.ts +43 -0
  77. package/dist/DateInput/DateInput.d.ts.map +1 -1
  78. package/dist/DateInput/DateInput.js +22 -2
  79. package/dist/DateInput/NativeDateField.d.ts +10 -0
  80. package/dist/DateInput/NativeDateField.d.ts.map +1 -0
  81. package/dist/DateInput/NativeDateField.js +494 -0
  82. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  83. package/dist/DateInput/TouchDateField.js +82 -23
  84. package/dist/DateInput/index.d.ts +1 -1
  85. package/dist/DateInput/index.d.ts.map +1 -1
  86. package/dist/DateInput/nativeDateSegments.d.ts +22 -0
  87. package/dist/DateInput/nativeDateSegments.d.ts.map +1 -0
  88. package/dist/DateInput/nativeDateSegments.js +111 -0
  89. package/dist/DateTimeInput/DateTimeInput.d.ts +21 -1
  90. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  91. package/dist/DateTimeInput/DateTimeInput.js +406 -12
  92. package/dist/DateTimeInput/index.d.ts +1 -1
  93. package/dist/DateTimeInput/index.d.ts.map +1 -1
  94. package/dist/Dialog/Dialog.d.ts +21 -1
  95. package/dist/Dialog/Dialog.d.ts.map +1 -1
  96. package/dist/Dialog/Dialog.js +110 -58
  97. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  98. package/dist/DropdownMenu/DropdownMenuSubMenu.js +6 -5
  99. package/dist/Field/FieldLabel.d.ts.map +1 -1
  100. package/dist/Field/FieldLabel.js +9 -1
  101. package/dist/FileInput/FileInput.d.ts.map +1 -1
  102. package/dist/FileInput/FileInput.js +3 -2
  103. package/dist/Heading/Heading.d.ts.map +1 -1
  104. package/dist/Heading/Heading.js +6 -2
  105. package/dist/HoverCard/HoverCard.d.ts +6 -0
  106. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  107. package/dist/HoverCard/HoverCard.js +104 -3
  108. package/dist/HoverCard/useHoverCard.d.ts +19 -2
  109. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  110. package/dist/HoverCard/useHoverCard.js +74 -26
  111. package/dist/Indicator/CheckboxIndicator.d.ts.map +1 -1
  112. package/dist/Indicator/CheckboxIndicator.js +6 -2
  113. package/dist/Item/Item.d.ts.map +1 -1
  114. package/dist/Item/Item.js +4 -2
  115. package/dist/Kbd/Kbd.d.ts.map +1 -1
  116. package/dist/Kbd/Kbd.js +2 -19
  117. package/dist/Layer/LayerDepthContext.d.ts +52 -0
  118. package/dist/Layer/LayerDepthContext.d.ts.map +1 -0
  119. package/dist/Layer/LayerDepthContext.js +66 -0
  120. package/dist/Layer/index.d.ts +3 -0
  121. package/dist/Layer/index.d.ts.map +1 -1
  122. package/dist/Layer/index.js +4 -0
  123. package/dist/Layer/layerStack.d.ts +98 -0
  124. package/dist/Layer/layerStack.d.ts.map +1 -0
  125. package/dist/Layer/layerStack.js +306 -0
  126. package/dist/Layer/useLayerDismissal.d.ts +93 -0
  127. package/dist/Layer/useLayerDismissal.d.ts.map +1 -0
  128. package/dist/Layer/useLayerDismissal.js +91 -0
  129. package/dist/Lightbox/Lightbox.d.ts +1 -0
  130. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  131. package/dist/Lightbox/Lightbox.js +103 -81
  132. package/dist/Markdown/Markdown.d.ts.map +1 -1
  133. package/dist/Markdown/Markdown.js +9 -4
  134. package/dist/Markdown/index.d.ts +1 -1
  135. package/dist/Markdown/index.d.ts.map +1 -1
  136. package/dist/Markdown/parser.d.ts +38 -1
  137. package/dist/Markdown/parser.d.ts.map +1 -1
  138. package/dist/Markdown/parser.js +182 -32
  139. package/dist/MobileNav/MobileNav.d.ts +6 -1
  140. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  141. package/dist/MobileNav/MobileNav.js +61 -36
  142. package/dist/MultiSelector/MultiSelector.d.ts +32 -1
  143. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  144. package/dist/MultiSelector/MultiSelector.js +63 -11
  145. package/dist/MultiSelector/index.d.ts +1 -1
  146. package/dist/MultiSelector/index.d.ts.map +1 -1
  147. package/dist/NavIcon/NavIcon.d.ts.map +1 -1
  148. package/dist/NavIcon/NavIcon.js +5 -1
  149. package/dist/NavMenu/NavHeadingMenu.d.ts.map +1 -1
  150. package/dist/NavMenu/NavHeadingMenu.js +3 -2
  151. package/dist/NumberInput/NumberInput.d.ts +6 -1
  152. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  153. package/dist/NumberInput/NumberInput.js +54 -104
  154. package/dist/NumberInput/numberInputCommit.d.ts +34 -0
  155. package/dist/NumberInput/numberInputCommit.d.ts.map +1 -0
  156. package/dist/NumberInput/numberInputCommit.js +71 -0
  157. package/dist/NumberInput/numberParser.d.ts +64 -0
  158. package/dist/NumberInput/numberParser.d.ts.map +1 -0
  159. package/dist/NumberInput/numberParser.js +350 -0
  160. package/dist/Outline/Outline.d.ts.map +1 -1
  161. package/dist/Outline/Outline.js +3 -2
  162. package/dist/OverflowList/OverflowList.d.ts.map +1 -1
  163. package/dist/OverflowList/OverflowList.js +3 -2
  164. package/dist/Overlay/Overlay.d.ts.map +1 -1
  165. package/dist/Overlay/Overlay.js +3 -2
  166. package/dist/Popover/usePopover.d.ts.map +1 -1
  167. package/dist/Popover/usePopover.js +20 -17
  168. package/dist/PowerSearch/PowerSearch.d.ts +8 -2
  169. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  170. package/dist/PowerSearch/PowerSearch.js +19 -6
  171. package/dist/PowerSearch/PowerSearchEditPopover.d.ts +3 -1
  172. package/dist/PowerSearch/PowerSearchEditPopover.d.ts.map +1 -1
  173. package/dist/PowerSearch/PowerSearchEditPopover.js +28 -6
  174. package/dist/PowerSearch/PowerSearchValueEditor.d.ts +3 -1
  175. package/dist/PowerSearch/PowerSearchValueEditor.d.ts.map +1 -1
  176. package/dist/PowerSearch/PowerSearchValueEditor.js +110 -27
  177. package/dist/PowerSearch/types.d.ts +2 -0
  178. package/dist/PowerSearch/types.d.ts.map +1 -1
  179. package/dist/PowerSearch/usePowerSearchSource.d.ts +6 -1
  180. package/dist/PowerSearch/usePowerSearchSource.d.ts.map +1 -1
  181. package/dist/PowerSearch/usePowerSearchSource.js +14 -5
  182. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  183. package/dist/ProgressBar/ProgressBar.js +18 -5
  184. package/dist/Resizable/ResizeHandle.d.ts.map +1 -1
  185. package/dist/Resizable/ResizeHandle.js +3 -2
  186. package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -1
  187. package/dist/SegmentedControl/SegmentedControl.js +3 -2
  188. package/dist/SelectableCard/SelectableCard.d.ts.map +1 -1
  189. package/dist/SelectableCard/SelectableCard.js +3 -3
  190. package/dist/Selector/Selector.d.ts +17 -0
  191. package/dist/Selector/Selector.d.ts.map +1 -1
  192. package/dist/Selector/Selector.js +81 -14
  193. package/dist/SideNav/SideNav.d.ts.map +1 -1
  194. package/dist/SideNav/SideNav.js +4 -3
  195. package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
  196. package/dist/SideNav/SideNavHeading.js +8 -7
  197. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  198. package/dist/SideNav/SideNavItem.js +4 -3
  199. package/dist/Slider/Slider.d.ts.map +1 -1
  200. package/dist/Slider/Slider.js +37 -5
  201. package/dist/Spinner/Spinner.d.ts +3 -3
  202. package/dist/Spinner/Spinner.d.ts.map +1 -1
  203. package/dist/Spinner/Spinner.js +130 -78
  204. package/dist/StatusDot/StatusDot.d.ts.map +1 -1
  205. package/dist/StatusDot/StatusDot.js +6 -1
  206. package/dist/Stepper/Step.d.ts +140 -0
  207. package/dist/Stepper/Step.d.ts.map +1 -0
  208. package/dist/Stepper/Step.js +1045 -0
  209. package/dist/Stepper/StepStatus.d.ts +24 -0
  210. package/dist/Stepper/StepStatus.d.ts.map +1 -0
  211. package/dist/Stepper/StepStatus.js +1 -0
  212. package/dist/Stepper/Stepper.d.ts +93 -0
  213. package/dist/Stepper/Stepper.d.ts.map +1 -0
  214. package/dist/Stepper/Stepper.js +184 -0
  215. package/dist/Stepper/StepperContext.d.ts +41 -0
  216. package/dist/Stepper/StepperContext.d.ts.map +1 -0
  217. package/dist/Stepper/StepperContext.js +34 -0
  218. package/dist/Stepper/index.d.ts +8 -0
  219. package/dist/Stepper/index.d.ts.map +1 -0
  220. package/dist/Stepper/index.js +7 -0
  221. package/dist/Stepper/stepper.stylex.d.ts +15 -0
  222. package/dist/Stepper/stepper.stylex.d.ts.map +1 -0
  223. package/dist/Stepper/stepper.stylex.js +20 -0
  224. package/dist/Switch/Switch.d.ts.map +1 -1
  225. package/dist/Switch/Switch.js +8 -3
  226. package/dist/TabList/Tab.d.ts +15 -2
  227. package/dist/TabList/Tab.d.ts.map +1 -1
  228. package/dist/TabList/Tab.js +32 -7
  229. package/dist/TabList/TabList.d.ts +46 -7
  230. package/dist/TabList/TabList.d.ts.map +1 -1
  231. package/dist/TabList/TabList.js +353 -23
  232. package/dist/TabList/TabListContext.d.ts +11 -1
  233. package/dist/TabList/TabListContext.d.ts.map +1 -1
  234. package/dist/TabList/TabListContext.js +11 -2
  235. package/dist/TabList/TabMenu.d.ts.map +1 -1
  236. package/dist/TabList/TabMenu.js +3 -2
  237. package/dist/TabList/index.d.ts +2 -2
  238. package/dist/TabList/index.d.ts.map +1 -1
  239. package/dist/Table/BaseTable.d.ts.map +1 -1
  240. package/dist/Table/BaseTable.js +10 -2
  241. package/dist/Table/Table.d.ts.map +1 -1
  242. package/dist/Table/Table.js +3 -6
  243. package/dist/Table/TableRow.d.ts +16 -5
  244. package/dist/Table/TableRow.d.ts.map +1 -1
  245. package/dist/Table/TableRow.js +16 -5
  246. package/dist/Table/plugins/filtering/useTableFiltering.d.ts.map +1 -1
  247. package/dist/Table/plugins/filtering/useTableFiltering.js +73 -4
  248. package/dist/Table/plugins/groupedRows/useTableGroupedRows.d.ts.map +1 -1
  249. package/dist/Table/plugins/groupedRows/useTableGroupedRows.js +20 -8
  250. package/dist/Table/plugins/pagination/useTablePagination.d.ts +3 -0
  251. package/dist/Table/plugins/pagination/useTablePagination.d.ts.map +1 -1
  252. package/dist/Table/plugins/pagination/useTablePagination.js +21 -4
  253. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  254. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +7 -2
  255. package/dist/Table/plugins/rowStatus/useTableRowStatus.d.ts.map +1 -1
  256. package/dist/Table/plugins/rowStatus/useTableRowStatus.js +10 -3
  257. package/dist/Table/plugins/selection/useTableSelection.d.ts +16 -0
  258. package/dist/Table/plugins/selection/useTableSelection.d.ts.map +1 -1
  259. package/dist/Table/plugins/selection/useTableSelection.js +19 -5
  260. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts.map +1 -1
  261. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.js +3 -4
  262. package/dist/Table/types.d.ts +22 -4
  263. package/dist/Table/types.d.ts.map +1 -1
  264. package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
  265. package/dist/Table/useBaseTablePlugins.js +5 -0
  266. package/dist/Text/Text.d.ts.map +1 -1
  267. package/dist/Text/Text.js +6 -2
  268. package/dist/TextArea/TextArea.d.ts.map +1 -1
  269. package/dist/TextArea/TextArea.js +9 -3
  270. package/dist/TextInput/TextInput.d.ts.map +1 -1
  271. package/dist/TextInput/TextInput.js +3 -2
  272. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  273. package/dist/TimeInput/TimeInput.js +3 -2
  274. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  275. package/dist/Timestamp/Timestamp.js +5 -3
  276. package/dist/Toast/ToastViewport.d.ts.map +1 -1
  277. package/dist/Toast/ToastViewport.js +27 -8
  278. package/dist/Token/TokenLink.d.ts.map +1 -1
  279. package/dist/Token/TokenLink.js +2 -2
  280. package/dist/Tokenizer/Tokenizer.d.ts +3 -1
  281. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  282. package/dist/Tokenizer/Tokenizer.js +2 -0
  283. package/dist/Tooltip/Tooltip.d.ts +5 -0
  284. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  285. package/dist/Tooltip/useTooltip.d.ts +5 -0
  286. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  287. package/dist/Tooltip/useTooltip.js +39 -24
  288. package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
  289. package/dist/TopNav/TopNavHeading.js +5 -4
  290. package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
  291. package/dist/TopNav/TopNavMegaMenu.js +3 -3
  292. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  293. package/dist/TopNav/TopNavMenu.js +4 -3
  294. package/dist/Typeahead/BaseTypeahead.d.ts +2 -0
  295. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  296. package/dist/Typeahead/BaseTypeahead.js +68 -29
  297. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  298. package/dist/Typeahead/Typeahead.js +3 -2
  299. package/dist/astryx.css +98 -7
  300. package/dist/hooks/index.d.ts +1 -0
  301. package/dist/hooks/index.d.ts.map +1 -1
  302. package/dist/hooks/index.js +1 -0
  303. package/dist/hooks/useFocusTrap.d.ts +7 -4
  304. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  305. package/dist/hooks/useFocusTrap.js +60 -95
  306. package/dist/hooks/useHotkeys.d.ts.map +1 -1
  307. package/dist/hooks/useHotkeys.js +3 -18
  308. package/dist/hooks/useMergedRefs.d.ts +18 -0
  309. package/dist/hooks/useMergedRefs.d.ts.map +1 -0
  310. package/dist/hooks/useMergedRefs.js +25 -0
  311. package/dist/index.d.ts +1 -0
  312. package/dist/index.d.ts.map +1 -1
  313. package/dist/index.js +1 -0
  314. package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
  315. package/dist/theme/derivedVarRegistry.js +24 -4
  316. package/dist/theme/syntax/tokens.d.ts +1 -1
  317. package/dist/theme/syntax/tokens.d.ts.map +1 -1
  318. package/dist/theme/syntax/tokens.js +5 -2
  319. package/dist/utils/groupItems.d.ts +5 -1
  320. package/dist/utils/groupItems.d.ts.map +1 -1
  321. package/dist/utils/groupItems.js +9 -9
  322. package/dist/utils/index.d.ts +1 -1
  323. package/dist/utils/index.d.ts.map +1 -1
  324. package/dist/utils/isApplePlatform.d.ts +18 -0
  325. package/dist/utils/isApplePlatform.d.ts.map +1 -0
  326. package/dist/utils/isApplePlatform.js +37 -0
  327. package/dist/utils/sharedResizeObserver.d.ts.map +1 -1
  328. package/dist/utils/sharedResizeObserver.js +10 -1
  329. package/dist/utils/timeParser.d.ts +1 -1
  330. package/dist/utils/timeParser.d.ts.map +1 -1
  331. package/dist/utils/timeParser.js +26 -12
  332. package/locales/en.json +154 -10
  333. package/locales/pseudo.json +108 -0
  334. package/package.json +8 -3
  335. package/src/AlertDialog/AlertDialog.doc.mjs +114 -30
  336. package/src/AlertDialog/AlertDialog.test.tsx +211 -2
  337. package/src/AlertDialog/AlertDialog.tsx +62 -20
  338. package/src/AppShell/AppShell.tsx +3 -2
  339. package/src/AspectRatio/AspectRatio.doc.mjs +6 -3
  340. package/src/AspectRatio/AspectRatio.test.tsx +57 -10
  341. package/src/AspectRatio/AspectRatio.tsx +40 -1
  342. package/src/Avatar/Avatar.doc.mjs +14 -3
  343. package/src/Avatar/Avatar.test.tsx +97 -0
  344. package/src/Avatar/Avatar.tsx +125 -52
  345. package/src/Avatar/index.ts +2 -2
  346. package/src/AvatarGroup/AvatarGroup.doc.mjs +8 -2
  347. package/src/AvatarGroup/AvatarGroup.test.tsx +24 -0
  348. package/src/AvatarGroup/AvatarGroup.tsx +15 -6
  349. package/src/AvatarGroup/AvatarGroupContext.ts +5 -3
  350. package/src/AvatarGroup/AvatarGroupOverflow.tsx +10 -4
  351. package/src/Banner/Banner.doc.mjs +13 -11
  352. package/src/Banner/Banner.test.tsx +102 -15
  353. package/src/Banner/Banner.tsx +72 -25
  354. package/src/BottomSheet/BottomSheet.test.tsx +123 -0
  355. package/src/BottomSheet/BottomSheet.tsx +17 -4
  356. package/src/BottomSheet/BottomSheetSwitcher.tsx +3 -2
  357. package/src/Breadcrumbs/BreadcrumbItem.tsx +9 -5
  358. package/src/Button/Button.doc.mjs +24 -1
  359. package/src/Button/Button.test.tsx +32 -4
  360. package/src/Button/Button.tsx +12 -5
  361. package/src/ButtonGroup/ButtonGroup.doc.mjs +10 -1
  362. package/src/ButtonGroup/ButtonGroup.test.tsx +404 -1
  363. package/src/ButtonGroup/ButtonGroup.tsx +23 -5
  364. package/src/Calendar/Calendar.doc.mjs +4 -3
  365. package/src/Calendar/Calendar.test.tsx +103 -0
  366. package/src/Calendar/Calendar.tsx +19 -16
  367. package/src/Calendar/getInitialFocusDate.test.ts +94 -0
  368. package/src/Calendar/getInitialFocusDate.ts +93 -0
  369. package/src/Calendar/styles.ts +49 -31
  370. package/src/Card/Card.doc.mjs +10 -7
  371. package/src/Card/Card.test.tsx +44 -19
  372. package/src/Card/Card.tsx +12 -32
  373. package/src/Carousel/Carousel.tsx +3 -2
  374. package/src/Chat/ChatComposerInput.tsx +5 -1
  375. package/src/Chat/ChatLayout.tsx +3 -2
  376. package/src/Chat/ChatMessageList.doc.mjs +9 -1
  377. package/src/Chat/ChatMessageList.test.tsx +46 -0
  378. package/src/Chat/ChatMessageList.tsx +28 -4
  379. package/src/Chat/ChatTokenizedText.tsx +17 -10
  380. package/src/Chat/useChatComposerTokens.ts +1 -1
  381. package/src/CheckboxInput/CheckboxInput.doc.mjs +2 -0
  382. package/src/CheckboxInput/CheckboxInput.test.tsx +15 -0
  383. package/src/CheckboxInput/CheckboxInput.tsx +8 -2
  384. package/src/ClickableCard/ClickableCard.tsx +3 -2
  385. package/src/CodeBlock/CodeBlock.doc.mjs +16 -4
  386. package/src/CodeBlock/CodeBlock.test.tsx +37 -0
  387. package/src/CodeBlock/CodeBlock.tsx +20 -4
  388. package/src/CodeBlock/highlightRanges.ts +1 -1
  389. package/src/CodeBlock/highlightStyles.ts +13 -13
  390. package/src/CommandPalette/CommandPaletteInput.tsx +3 -2
  391. package/src/CommandPalette/CommandPaletteItem.tsx +3 -2
  392. package/src/ContextMenu/ContextMenu.doc.mjs +5 -0
  393. package/src/ContextMenu/ContextMenu.tsx +4 -3
  394. package/src/DateInput/DateInput.doc.mjs +16 -0
  395. package/src/DateInput/DateInput.tsx +55 -2
  396. package/src/DateInput/DateInputTouch.test.tsx +146 -32
  397. package/src/DateInput/NativeDateField.test.tsx +635 -0
  398. package/src/DateInput/NativeDateField.tsx +641 -0
  399. package/src/DateInput/TouchDateField.tsx +108 -24
  400. package/src/DateInput/index.ts +1 -0
  401. package/src/DateInput/nativeDateSegments.ts +127 -0
  402. package/src/DateTimeInput/DateTimeInput.doc.mjs +18 -0
  403. package/src/DateTimeInput/DateTimeInput.test.tsx +1088 -0
  404. package/src/DateTimeInput/DateTimeInput.tsx +556 -24
  405. package/src/DateTimeInput/index.ts +1 -0
  406. package/src/Dialog/Dialog.doc.mjs +8 -3
  407. package/src/Dialog/Dialog.test.tsx +429 -2
  408. package/src/Dialog/Dialog.tsx +133 -58
  409. package/src/DropdownMenu/DropdownMenu.doc.mjs +6 -0
  410. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +8 -5
  411. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +8 -11
  412. package/src/Field/Field.doc.mjs +13 -2
  413. package/src/Field/FieldLabel.test.tsx +29 -0
  414. package/src/Field/FieldLabel.tsx +7 -0
  415. package/src/FileInput/FileInput.tsx +3 -2
  416. package/src/Heading/Heading.test.tsx +23 -1
  417. package/src/Heading/Heading.tsx +6 -2
  418. package/src/HoverCard/HoverCard.doc.mjs +13 -2
  419. package/src/HoverCard/HoverCard.test.tsx +154 -4
  420. package/src/HoverCard/HoverCard.tsx +143 -3
  421. package/src/HoverCard/useHoverCard.tsx +104 -33
  422. package/src/Indicator/CheckboxIndicator.tsx +14 -8
  423. package/src/Indicator/Indicator.doc.mjs +2 -0
  424. package/src/Item/Item.tsx +5 -4
  425. package/src/Kbd/Kbd.tsx +2 -19
  426. package/src/Layer/LayerDepthContext.tsx +62 -0
  427. package/src/Layer/index.ts +9 -0
  428. package/src/Layer/layerDismissalFamilies.test.tsx +359 -0
  429. package/src/Layer/layerDismissalInvariants.test.tsx +417 -0
  430. package/src/Layer/layerStack.ts +362 -0
  431. package/src/Layer/useLayer.test.tsx +62 -14
  432. package/src/Layer/useLayerDismissal.test.tsx +469 -0
  433. package/src/Layer/useLayerDismissal.ts +173 -0
  434. package/src/Layout/Layout.doc.mjs +2 -2
  435. package/src/Lightbox/Lightbox.doc.mjs +12 -0
  436. package/src/Lightbox/Lightbox.tsx +124 -102
  437. package/src/Link/Link.doc.mjs +39 -0
  438. package/src/Markdown/Markdown.doc.mjs +5 -0
  439. package/src/Markdown/Markdown.tsx +9 -4
  440. package/src/Markdown/incremental.test.ts +16 -2
  441. package/src/Markdown/index.ts +1 -0
  442. package/src/Markdown/parser.perf.test.ts +96 -1
  443. package/src/Markdown/parser.test.ts +178 -2
  444. package/src/Markdown/parser.ts +253 -32
  445. package/src/MobileNav/MobileNav.tsx +62 -36
  446. package/src/MoreMenu/MoreMenu.doc.mjs +7 -1
  447. package/src/MultiSelector/MultiSelector.doc.mjs +49 -1
  448. package/src/MultiSelector/MultiSelector.test.tsx +277 -0
  449. package/src/MultiSelector/MultiSelector.tsx +142 -11
  450. package/src/MultiSelector/index.ts +1 -0
  451. package/src/NavIcon/NavIcon.doc.mjs +8 -2
  452. package/src/NavIcon/NavIcon.test.tsx +9 -0
  453. package/src/NavIcon/NavIcon.tsx +5 -1
  454. package/src/NavMenu/NavHeadingMenu.tsx +3 -2
  455. package/src/NumberInput/NumberInput.doc.mjs +31 -7
  456. package/src/NumberInput/NumberInput.test.tsx +436 -8
  457. package/src/NumberInput/NumberInput.tsx +62 -114
  458. package/src/NumberInput/numberInputCommit.test.ts +72 -0
  459. package/src/NumberInput/numberInputCommit.ts +103 -0
  460. package/src/NumberInput/numberParser.docblock.test.ts +234 -0
  461. package/src/NumberInput/numberParser.test.ts +222 -0
  462. package/src/NumberInput/numberParser.ts +403 -0
  463. package/src/Outline/Outline.tsx +3 -2
  464. package/src/OverflowList/OverflowList.tsx +3 -2
  465. package/src/Overlay/Overlay.tsx +3 -2
  466. package/src/Pagination/Pagination.test.tsx +3 -2
  467. package/src/Popover/usePopover.tsx +31 -28
  468. package/src/PowerSearch/PowerSearch.doc.mjs +160 -28
  469. package/src/PowerSearch/PowerSearch.test.tsx +240 -1
  470. package/src/PowerSearch/PowerSearch.tsx +34 -5
  471. package/src/PowerSearch/PowerSearchEditPopover.test.tsx +288 -0
  472. package/src/PowerSearch/PowerSearchEditPopover.tsx +29 -2
  473. package/src/PowerSearch/PowerSearchValueEditor.test.tsx +111 -1
  474. package/src/PowerSearch/PowerSearchValueEditor.tsx +124 -10
  475. package/src/PowerSearch/types.ts +2 -0
  476. package/src/PowerSearch/usePowerSearchSource.test.ts +82 -2
  477. package/src/PowerSearch/usePowerSearchSource.ts +13 -3
  478. package/src/ProgressBar/ProgressBar.doc.mjs +42 -6
  479. package/src/ProgressBar/ProgressBar.test.tsx +24 -0
  480. package/src/ProgressBar/ProgressBar.tsx +28 -8
  481. package/src/Resizable/ResizeHandle.tsx +3 -2
  482. package/src/SegmentedControl/SegmentedControl.tsx +3 -2
  483. package/src/SelectableCard/SelectableCard.tsx +3 -2
  484. package/src/Selector/Selector.doc.mjs +26 -1
  485. package/src/Selector/Selector.test.tsx +311 -1
  486. package/src/Selector/Selector.tsx +139 -16
  487. package/src/SideNav/SideNav.doc.mjs +6 -6
  488. package/src/SideNav/SideNav.tsx +4 -2
  489. package/src/SideNav/SideNavHeading.tsx +12 -12
  490. package/src/SideNav/SideNavItem.tsx +4 -2
  491. package/src/Slider/Slider.test.tsx +106 -1
  492. package/src/Slider/Slider.tsx +55 -3
  493. package/src/Spinner/Spinner.test.tsx +147 -1
  494. package/src/Spinner/Spinner.tsx +120 -101
  495. package/src/StatusDot/StatusDot.doc.mjs +8 -2
  496. package/src/StatusDot/StatusDot.test.tsx +9 -0
  497. package/src/StatusDot/StatusDot.tsx +7 -1
  498. package/src/Stepper/Step.doc.mjs +98 -0
  499. package/src/Stepper/Step.tsx +1638 -0
  500. package/src/Stepper/StepStatus.ts +26 -0
  501. package/src/Stepper/Stepper.doc.mjs +370 -0
  502. package/src/Stepper/Stepper.test.tsx +1024 -0
  503. package/src/Stepper/Stepper.tsx +258 -0
  504. package/src/Stepper/StepperContext.ts +71 -0
  505. package/src/Stepper/index.ts +18 -0
  506. package/src/Stepper/stepper.stylex.ts +19 -0
  507. package/src/Switch/Switch.doc.mjs +2 -0
  508. package/src/Switch/Switch.test.tsx +10 -0
  509. package/src/Switch/Switch.tsx +6 -2
  510. package/src/TabList/Tab.doc.mjs +13 -1
  511. package/src/TabList/Tab.tsx +67 -7
  512. package/src/TabList/TabList.doc.mjs +22 -6
  513. package/src/TabList/TabList.test.tsx +559 -0
  514. package/src/TabList/TabList.tsx +490 -22
  515. package/src/TabList/TabListContext.ts +13 -2
  516. package/src/TabList/TabMenu.tsx +3 -2
  517. package/src/TabList/index.ts +6 -2
  518. package/src/Table/BaseTable.tsx +12 -4
  519. package/src/Table/Table.doc.mjs +9 -2
  520. package/src/Table/Table.test.tsx +132 -14
  521. package/src/Table/Table.tsx +3 -8
  522. package/src/Table/TableBody.doc.mjs +40 -0
  523. package/src/Table/TableFooter.doc.mjs +40 -0
  524. package/src/Table/TableHeader.doc.mjs +40 -0
  525. package/src/Table/TableRow.tsx +16 -5
  526. package/src/Table/plugins/filtering/useTableFiltering.test.tsx +44 -6
  527. package/src/Table/plugins/filtering/useTableFiltering.tsx +100 -4
  528. package/src/Table/plugins/groupedRows/useTableGroupedRows-perf.test.tsx +112 -0
  529. package/src/Table/plugins/groupedRows/useTableGroupedRows.test.tsx +100 -0
  530. package/src/Table/plugins/groupedRows/useTableGroupedRows.tsx +17 -8
  531. package/src/Table/plugins/pagination/useTablePagination.test.tsx +34 -4
  532. package/src/Table/plugins/pagination/useTablePagination.tsx +35 -3
  533. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +9 -2
  534. package/src/Table/plugins/rowStatus/useTableRowStatus.test.tsx +13 -7
  535. package/src/Table/plugins/rowStatus/useTableRowStatus.tsx +12 -3
  536. package/src/Table/plugins/selection/useTableSelection.test.tsx +76 -0
  537. package/src/Table/plugins/selection/useTableSelection.tsx +40 -7
  538. package/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +1 -2
  539. package/src/Table/types.ts +22 -4
  540. package/src/Table/useBaseTablePlugins.ts +5 -0
  541. package/src/Table/useTableGroupedRows.doc.mjs +5 -4
  542. package/src/Table/useTablePagination.doc.mjs +7 -0
  543. package/src/Table/useTableRowStatus.doc.mjs +1 -1
  544. package/src/Table/useTableSelection.doc.mjs +31 -0
  545. package/src/Text/Text.test.tsx +23 -1
  546. package/src/Text/Text.tsx +6 -2
  547. package/src/TextArea/TextArea.doc.mjs +8 -2
  548. package/src/TextArea/TextArea.test.tsx +11 -0
  549. package/src/TextArea/TextArea.tsx +15 -8
  550. package/src/TextInput/TextInput.doc.mjs +10 -0
  551. package/src/TextInput/TextInput.tsx +3 -2
  552. package/src/Thumbnail/Thumbnail.doc.mjs +1 -1
  553. package/src/TimeInput/TimeInput.tsx +2 -2
  554. package/src/Timestamp/Timestamp.test.tsx +18 -0
  555. package/src/Timestamp/Timestamp.tsx +4 -2
  556. package/src/Toast/Toast.doc.mjs +6 -0
  557. package/src/Toast/ToastViewport.tsx +17 -1
  558. package/src/Token/TokenLink.tsx +2 -2
  559. package/src/Tokenizer/Tokenizer.doc.mjs +27 -0
  560. package/src/Tokenizer/Tokenizer.tsx +4 -0
  561. package/src/Toolbar/Toolbar.doc.mjs +5 -0
  562. package/src/Tooltip/Tooltip.doc.mjs +5 -0
  563. package/src/Tooltip/Tooltip.test.tsx +32 -0
  564. package/src/Tooltip/Tooltip.tsx +5 -0
  565. package/src/Tooltip/useTooltip.tsx +47 -24
  566. package/src/TopNav/TopNavHeading.tsx +5 -4
  567. package/src/TopNav/TopNavMegaMenu.tsx +3 -2
  568. package/src/TopNav/TopNavMenu.tsx +4 -3
  569. package/src/Typeahead/BaseTypeahead.doc.mjs +11 -0
  570. package/src/Typeahead/BaseTypeahead.tsx +85 -33
  571. package/src/Typeahead/Typeahead.doc.mjs +5 -0
  572. package/src/Typeahead/Typeahead.test.tsx +49 -0
  573. package/src/Typeahead/Typeahead.tsx +3 -2
  574. package/src/hooks/index.ts +2 -0
  575. package/src/hooks/useContainerReveal.doc.mjs +2 -2
  576. package/src/hooks/useFocusTrap.test.tsx +14 -0
  577. package/src/hooks/useFocusTrap.ts +62 -111
  578. package/src/hooks/useFocusTrapEscapeShim.test.tsx +165 -0
  579. package/src/hooks/useHotkeys.ts +3 -18
  580. package/src/hooks/useMergedRefs.doc.mjs +79 -0
  581. package/src/hooks/useMergedRefs.test.tsx +62 -0
  582. package/src/hooks/useMergedRefs.ts +36 -0
  583. package/src/index.ts +1 -0
  584. package/src/raw-import.d.ts +14 -0
  585. package/src/theme/MediaTheme.doc.mjs +5 -5
  586. package/src/theme/derivedVarRegistry.test.ts +19 -3
  587. package/src/theme/derivedVarRegistry.ts +23 -4
  588. package/src/theme/generateThemeRules.test.ts +37 -0
  589. package/src/theme/syntax/tokens.ts +5 -2
  590. package/src/utils/groupItems.ts +12 -6
  591. package/src/utils/index.ts +1 -1
  592. package/src/utils/isApplePlatform.test.ts +61 -0
  593. package/src/utils/isApplePlatform.ts +37 -0
  594. package/src/utils/sharedResizeObserver.ts +10 -2
  595. package/src/utils/timeParser.test.ts +6 -1
  596. package/src/utils/timeParser.ts +35 -13
  597. package/src/__tests__/inputWidthContract.test.tsx +0 -69
@@ -942,6 +942,1094 @@ describe('DateTimeInput', () => {
942
942
  });
943
943
  });
944
944
 
945
+ // ===========================================================================
946
+ // Time-option dropdown (#2727)
947
+ // ===========================================================================
948
+
949
+ describe('timeOptionInterval', () => {
950
+ // The dropdown renders through the same popover as the calendar, so its
951
+ // content lands in the top layer: jsdom keeps the nodes in the DOM but
952
+ // role queries skip them. Read the options off the container directly,
953
+ // exactly as the weekStartsOn tests read columnheaders.
954
+ const optionsIn = (container: HTMLElement): HTMLElement[] =>
955
+ Array.from(container.querySelectorAll('[role="option"]'));
956
+
957
+ const labelsIn = (container: HTMLElement): (string | null)[] =>
958
+ optionsIn(container).map(o => o.textContent);
959
+
960
+ // ---- opt-in gate --------------------------------------------------------
961
+
962
+ it('adds no listbox and no second combobox without timeOptionInterval', () => {
963
+ const {container} = render(
964
+ <DateTimeInput
965
+ label="Meeting"
966
+ value={'2026-03-15T14:30' as ISODateTimeString}
967
+ onChange={() => {}}
968
+ />,
969
+ );
970
+
971
+ // The date input stays the only combobox on the field. 30+ existing
972
+ // assertions use the singular getByRole('combobox'); a second one would
973
+ // break every one of them, and every downstream consumer's too.
974
+ expect(screen.getAllByRole('combobox')).toHaveLength(1);
975
+
976
+ const timeInput = screen.getByLabelText('Meeting time');
977
+ expect(timeInput).not.toHaveAttribute('role', 'combobox');
978
+ fireEvent.click(timeInput);
979
+ expect(optionsIn(container)).toHaveLength(0);
980
+ });
981
+
982
+ // ---- option generation --------------------------------------------------
983
+
984
+ it('renders one option per interval step across the day', () => {
985
+ const {container} = render(
986
+ <DateTimeInput
987
+ label="Meeting"
988
+ value={'2026-03-15T14:30' as ISODateTimeString}
989
+ timeOptionInterval={30}
990
+ onChange={() => {}}
991
+ />,
992
+ );
993
+
994
+ fireEvent.click(screen.getByLabelText('Meeting time'));
995
+ // A full day at 30-minute cadence.
996
+ expect(optionsIn(container)).toHaveLength(48);
997
+ });
998
+
999
+ it('renders 24 options at an hourly interval', () => {
1000
+ const {container} = render(
1001
+ <DateTimeInput
1002
+ label="Meeting"
1003
+ value={'2026-03-15T14:30' as ISODateTimeString}
1004
+ timeOptionInterval={60}
1005
+ onChange={() => {}}
1006
+ />,
1007
+ );
1008
+
1009
+ fireEvent.click(screen.getByLabelText('Meeting time'));
1010
+ const labels = labelsIn(container);
1011
+ expect(labels).toHaveLength(24);
1012
+ // The 12 AM - 11 PM list the issue asks for.
1013
+ expect(labels[0]).toBe('12:00 AM');
1014
+ expect(labels[23]).toBe('11:00 PM');
1015
+ });
1016
+
1017
+ it('labels options in 24-hour format when hourFormat is 24h', () => {
1018
+ const {container} = render(
1019
+ <DateTimeInput
1020
+ label="Meeting"
1021
+ value={'2026-03-15T14:30' as ISODateTimeString}
1022
+ timeOptionInterval={60}
1023
+ hourFormat="24h"
1024
+ onChange={() => {}}
1025
+ />,
1026
+ );
1027
+
1028
+ fireEvent.click(screen.getByLabelText('Meeting time'));
1029
+ const labels = labelsIn(container);
1030
+ expect(labels[0]).toBe('00:00');
1031
+ expect(labels[13]).toBe('13:00');
1032
+ });
1033
+
1034
+ it('includes seconds in option labels when hasSeconds is set', () => {
1035
+ const {container} = render(
1036
+ <DateTimeInput
1037
+ label="Meeting"
1038
+ value={'2026-03-15T14:30:00' as ISODateTimeString}
1039
+ timeOptionInterval={60}
1040
+ hasSeconds
1041
+ onChange={() => {}}
1042
+ />,
1043
+ );
1044
+
1045
+ fireEvent.click(screen.getByLabelText('Meeting time'));
1046
+ expect(labelsIn(container)[0]).toBe('12:00:00 AM');
1047
+ });
1048
+
1049
+ // ---- committing ---------------------------------------------------------
1050
+
1051
+ it('commits the clicked option and keeps the selected date', () => {
1052
+ const onChange = vi.fn();
1053
+ const {container} = render(
1054
+ <DateTimeInput
1055
+ label="Meeting"
1056
+ value={'2026-03-15T14:30' as ISODateTimeString}
1057
+ timeOptionInterval={60}
1058
+ onChange={onChange}
1059
+ />,
1060
+ );
1061
+
1062
+ fireEvent.click(screen.getByLabelText('Meeting time'));
1063
+ // 09:00 is the tenth hourly option.
1064
+ fireEvent.click(optionsIn(container)[9]);
1065
+
1066
+ expect(onChange).toHaveBeenCalledTimes(1);
1067
+ expect(onChange.mock.calls[0][0]).toBe('2026-03-15T09:00');
1068
+ });
1069
+
1070
+ it('marks the committed option as selected, not merely highlighted', () => {
1071
+ const {container} = render(
1072
+ <DateTimeInput
1073
+ label="Meeting"
1074
+ value={'2026-03-15T14:00' as ISODateTimeString}
1075
+ timeOptionInterval={60}
1076
+ onChange={() => {}}
1077
+ />,
1078
+ );
1079
+
1080
+ fireEvent.click(screen.getByLabelText('Meeting time'));
1081
+ const selected = optionsIn(container).filter(
1082
+ o => o.getAttribute('aria-selected') === 'true',
1083
+ );
1084
+ expect(selected).toHaveLength(1);
1085
+ expect(selected[0].textContent).toBe('2:00 PM');
1086
+ });
1087
+
1088
+ // ---- keyboard: the arrow-key collision ----------------------------------
1089
+
1090
+ it('keeps ArrowUp stepping by timeIncrement while the list is closed', () => {
1091
+ const onChange = vi.fn();
1092
+ render(
1093
+ <DateTimeInput
1094
+ label="Meeting"
1095
+ value={'2026-03-15T14:30' as ISODateTimeString}
1096
+ timeIncrement={15}
1097
+ timeOptionInterval={30}
1098
+ onChange={onChange}
1099
+ />,
1100
+ );
1101
+
1102
+ const timeInput = screen.getByLabelText('Meeting time');
1103
+ // Closed list: arrows still step the value, as they always have.
1104
+ fireEvent.keyDown(timeInput, {key: 'ArrowUp'});
1105
+
1106
+ expect(onChange).toHaveBeenCalledTimes(1);
1107
+ expect(onChange.mock.calls[0][0]).toContain('14:45');
1108
+ expect(timeInput).toHaveAttribute('aria-expanded', 'false');
1109
+ });
1110
+
1111
+ it('opens the list on Alt+ArrowDown without changing the value', () => {
1112
+ const onChange = vi.fn();
1113
+ const {container} = render(
1114
+ <DateTimeInput
1115
+ label="Meeting"
1116
+ value={'2026-03-15T14:30' as ISODateTimeString}
1117
+ timeOptionInterval={30}
1118
+ onChange={onChange}
1119
+ />,
1120
+ );
1121
+
1122
+ const timeInput = screen.getByLabelText('Meeting time');
1123
+ fireEvent.keyDown(timeInput, {key: 'ArrowDown', altKey: true});
1124
+
1125
+ expect(timeInput).toHaveAttribute('aria-expanded', 'true');
1126
+ expect(onChange).not.toHaveBeenCalled();
1127
+ expect(optionsIn(container)).toHaveLength(48);
1128
+ });
1129
+
1130
+ it('moves the active option with ArrowDown while open, without committing', () => {
1131
+ const onChange = vi.fn();
1132
+ const {container} = render(
1133
+ <DateTimeInput
1134
+ label="Meeting"
1135
+ value={'2026-03-15T14:00' as ISODateTimeString}
1136
+ timeOptionInterval={60}
1137
+ onChange={onChange}
1138
+ />,
1139
+ );
1140
+
1141
+ const timeInput = screen.getByLabelText('Meeting time');
1142
+ fireEvent.keyDown(timeInput, {key: 'ArrowDown', altKey: true});
1143
+ // Opening starts the highlight on the committed value (14:00).
1144
+ expect(timeInput).toHaveAttribute(
1145
+ 'aria-activedescendant',
1146
+ optionsIn(container)[14].id,
1147
+ );
1148
+
1149
+ fireEvent.keyDown(timeInput, {key: 'ArrowDown'});
1150
+ expect(timeInput).toHaveAttribute(
1151
+ 'aria-activedescendant',
1152
+ optionsIn(container)[15].id,
1153
+ );
1154
+ // Navigating is not selecting.
1155
+ expect(onChange).not.toHaveBeenCalled();
1156
+ });
1157
+
1158
+ it('commits the active option on Enter and closes the list', () => {
1159
+ const onChange = vi.fn();
1160
+ render(
1161
+ <DateTimeInput
1162
+ label="Meeting"
1163
+ value={'2026-03-15T14:00' as ISODateTimeString}
1164
+ timeOptionInterval={60}
1165
+ onChange={onChange}
1166
+ />,
1167
+ );
1168
+
1169
+ const timeInput = screen.getByLabelText('Meeting time');
1170
+ fireEvent.keyDown(timeInput, {key: 'ArrowDown', altKey: true});
1171
+ fireEvent.keyDown(timeInput, {key: 'ArrowDown'});
1172
+ fireEvent.keyDown(timeInput, {key: 'Enter'});
1173
+
1174
+ expect(onChange).toHaveBeenCalledTimes(1);
1175
+ expect(onChange.mock.calls[0][0]).toBe('2026-03-15T15:00');
1176
+ expect(timeInput).toHaveAttribute('aria-expanded', 'false');
1177
+ });
1178
+
1179
+ it('jumps to the first and last option with Home and End', () => {
1180
+ const {container} = render(
1181
+ <DateTimeInput
1182
+ label="Meeting"
1183
+ value={'2026-03-15T14:00' as ISODateTimeString}
1184
+ timeOptionInterval={60}
1185
+ onChange={() => {}}
1186
+ />,
1187
+ );
1188
+
1189
+ const timeInput = screen.getByLabelText('Meeting time');
1190
+ fireEvent.keyDown(timeInput, {key: 'ArrowDown', altKey: true});
1191
+
1192
+ fireEvent.keyDown(timeInput, {key: 'Home'});
1193
+ expect(timeInput).toHaveAttribute(
1194
+ 'aria-activedescendant',
1195
+ optionsIn(container)[0].id,
1196
+ );
1197
+
1198
+ fireEvent.keyDown(timeInput, {key: 'End'});
1199
+ expect(timeInput).toHaveAttribute(
1200
+ 'aria-activedescendant',
1201
+ optionsIn(container)[23].id,
1202
+ );
1203
+ });
1204
+
1205
+ it('closes on Escape without committing', () => {
1206
+ const onChange = vi.fn();
1207
+ render(
1208
+ <DateTimeInput
1209
+ label="Meeting"
1210
+ value={'2026-03-15T14:00' as ISODateTimeString}
1211
+ timeOptionInterval={60}
1212
+ onChange={onChange}
1213
+ />,
1214
+ );
1215
+
1216
+ const timeInput = screen.getByLabelText('Meeting time');
1217
+ fireEvent.keyDown(timeInput, {key: 'ArrowDown', altKey: true});
1218
+ fireEvent.keyDown(timeInput, {key: 'ArrowDown'});
1219
+ fireEvent.keyDown(timeInput, {key: 'Escape'});
1220
+
1221
+ expect(timeInput).toHaveAttribute('aria-expanded', 'false');
1222
+ expect(onChange).not.toHaveBeenCalled();
1223
+ });
1224
+
1225
+ // ---- typed entry keeps working -----------------------------------------
1226
+
1227
+ it('moves the highlight to the closest option as the user types, without dropping options', () => {
1228
+ const {container} = render(
1229
+ <DateTimeInput
1230
+ label="Meeting"
1231
+ value={'2026-03-15T14:00' as ISODateTimeString}
1232
+ timeOptionInterval={60}
1233
+ onChange={() => {}}
1234
+ />,
1235
+ );
1236
+
1237
+ const timeInput = screen.getByLabelText('Meeting time');
1238
+ fireEvent.click(timeInput);
1239
+ fireEvent.change(timeInput, {target: {value: '9:00 PM'}});
1240
+
1241
+ // Free-form entry is the contract here: the list narrows nothing, it
1242
+ // follows the typed value so Enter still lands on something sensible.
1243
+ expect(optionsIn(container)).toHaveLength(24);
1244
+ expect(timeInput).toHaveAttribute(
1245
+ 'aria-activedescendant',
1246
+ optionsIn(container)[21].id,
1247
+ );
1248
+ });
1249
+
1250
+ // ---- constraints --------------------------------------------------------
1251
+
1252
+ it('drops options outside min/max on the boundary date', () => {
1253
+ const {container} = render(
1254
+ <DateTimeInput
1255
+ label="Meeting"
1256
+ value={'2026-03-15T14:00' as ISODateTimeString}
1257
+ min={'2026-03-15T09:00' as ISODateTimeString}
1258
+ max={'2026-03-15T17:00' as ISODateTimeString}
1259
+ timeOptionInterval={60}
1260
+ onChange={() => {}}
1261
+ />,
1262
+ );
1263
+
1264
+ fireEvent.click(screen.getByLabelText('Meeting time'));
1265
+ const labels = labelsIn(container);
1266
+ // 09:00 through 17:00 inclusive.
1267
+ expect(labels).toHaveLength(9);
1268
+ expect(labels[0]).toBe('9:00 AM');
1269
+ expect(labels[8]).toBe('5:00 PM');
1270
+ });
1271
+
1272
+ it('offers the whole day when the selected date is inside the min/max range', () => {
1273
+ const {container} = render(
1274
+ <DateTimeInput
1275
+ label="Meeting"
1276
+ value={'2026-03-16T14:00' as ISODateTimeString}
1277
+ min={'2026-03-15T09:00' as ISODateTimeString}
1278
+ max={'2026-03-17T17:00' as ISODateTimeString}
1279
+ timeOptionInterval={60}
1280
+ onChange={() => {}}
1281
+ />,
1282
+ );
1283
+
1284
+ fireEvent.click(screen.getByLabelText('Meeting time'));
1285
+ // The time bound only bites on the boundary date itself.
1286
+ expect(optionsIn(container)).toHaveLength(24);
1287
+ });
1288
+
1289
+ // ---- a11y wiring + disabled --------------------------------------------
1290
+
1291
+ it('wires the time input as a combobox onto the listbox', () => {
1292
+ const {container} = render(
1293
+ <DateTimeInput
1294
+ label="Meeting"
1295
+ value={'2026-03-15T14:00' as ISODateTimeString}
1296
+ timeOptionInterval={60}
1297
+ onChange={() => {}}
1298
+ />,
1299
+ );
1300
+
1301
+ const timeInput = screen.getByLabelText('Meeting time');
1302
+ expect(timeInput).toHaveAttribute('role', 'combobox');
1303
+ expect(timeInput).toHaveAttribute('aria-expanded', 'false');
1304
+ expect(timeInput).toHaveAttribute('aria-autocomplete', 'list');
1305
+
1306
+ fireEvent.click(timeInput);
1307
+ const listbox = container.querySelector('[role="listbox"]');
1308
+ expect(listbox).not.toBeNull();
1309
+ expect(timeInput).toHaveAttribute('aria-controls', listbox?.id);
1310
+ // Named, so the list is not an anonymous group to a screen reader.
1311
+ expect(listbox).toHaveAttribute('aria-label');
1312
+ });
1313
+
1314
+ it('does not open the list when disabled', () => {
1315
+ render(
1316
+ <DateTimeInput
1317
+ label="Meeting"
1318
+ value={'2026-03-15T14:00' as ISODateTimeString}
1319
+ timeOptionInterval={60}
1320
+ isDisabled
1321
+ onChange={() => {}}
1322
+ />,
1323
+ );
1324
+
1325
+ const timeInput = screen.getByLabelText('Meeting time');
1326
+ fireEvent.click(timeInput);
1327
+ fireEvent.keyDown(timeInput, {key: 'ArrowDown', altKey: true});
1328
+
1329
+ // The layer mounts its content whether or not it is open — visibility is
1330
+ // the popover attribute's job, which jsdom does not apply. So assert the
1331
+ // open state itself, not the presence of option nodes.
1332
+ expect(timeInput).toHaveAttribute('aria-expanded', 'false');
1333
+ expect(timeInput).not.toHaveAttribute('aria-controls');
1334
+ });
1335
+
1336
+ it('does not open the list for a focusable-disabled field', () => {
1337
+ // With disabledMessage the input drops the native `disabled` attribute so
1338
+ // the reason stays discoverable, which means clicks and keys really do
1339
+ // reach it. This is the case the guards have to catch — a natively
1340
+ // disabled input never delivers the events in the first place.
1341
+ render(
1342
+ <DateTimeInput
1343
+ label="Meeting"
1344
+ value={'2026-03-15T14:00' as ISODateTimeString}
1345
+ timeOptionInterval={60}
1346
+ isDisabled
1347
+ disabledMessage="Pick a project first"
1348
+ onChange={() => {}}
1349
+ />,
1350
+ );
1351
+
1352
+ const timeInput = screen.getByLabelText('Meeting time');
1353
+ expect(timeInput).not.toBeDisabled();
1354
+
1355
+ fireEvent.click(timeInput);
1356
+ expect(timeInput).toHaveAttribute('aria-expanded', 'false');
1357
+
1358
+ fireEvent.keyDown(timeInput, {key: 'ArrowDown', altKey: true});
1359
+ expect(timeInput).toHaveAttribute('aria-expanded', 'false');
1360
+ });
1361
+
1362
+ // -- edge cases ----------------------------------------------------------
1363
+
1364
+ it('does not fire a change when the already-selected option is clicked', () => {
1365
+ // The marker compares a seconds-normalised time while the commit path
1366
+ // compared the raw one, so with a stray-seconds value the option that
1367
+ // renders as selected still emitted a change when clicked.
1368
+ const onChange = vi.fn();
1369
+ const {container} = render(
1370
+ <DateTimeInput
1371
+ label="Meeting"
1372
+ value={'2026-03-15T14:00:00' as ISODateTimeString}
1373
+ timeOptionInterval={60}
1374
+ onChange={onChange}
1375
+ />,
1376
+ );
1377
+
1378
+ const timeInput = screen.getByLabelText('Meeting time');
1379
+ fireEvent.click(timeInput);
1380
+ const selected = optionsIn(container).find(
1381
+ o => o.getAttribute('aria-selected') === 'true',
1382
+ );
1383
+ expect(selected).toBeDefined();
1384
+
1385
+ fireEvent.click(selected as HTMLElement);
1386
+
1387
+ expect(onChange).not.toHaveBeenCalled();
1388
+ expect(timeInput).toHaveAttribute('aria-expanded', 'false');
1389
+ });
1390
+
1391
+ it('does not open an empty list when min/max leave no valid option', () => {
1392
+ // A window narrower than the cadence survives no option at all. Opening
1393
+ // then shows a blank popover with nothing to pick.
1394
+ render(
1395
+ <DateTimeInput
1396
+ label="Meeting"
1397
+ value={'2026-03-15T09:15' as ISODateTimeString}
1398
+ min={'2026-03-15T09:10' as ISODateTimeString}
1399
+ max={'2026-03-15T09:20' as ISODateTimeString}
1400
+ timeOptionInterval={30}
1401
+ onChange={() => {}}
1402
+ />,
1403
+ );
1404
+
1405
+ const timeInput = screen.getByLabelText('Meeting time');
1406
+ fireEvent.click(timeInput);
1407
+ expect(timeInput).toHaveAttribute('aria-expanded', 'false');
1408
+
1409
+ fireEvent.keyDown(timeInput, {key: 'ArrowDown', altKey: true});
1410
+ expect(timeInput).toHaveAttribute('aria-expanded', 'false');
1411
+ });
1412
+
1413
+ it('commits the active option on Enter once the user arrows away from what they typed', () => {
1414
+ // Typed text beats the highlight only while the highlight is still the
1415
+ // one typing derived. After an arrow key the highlight is the user's own
1416
+ // choice, and Enter must honour what the field says is active.
1417
+ const onChange = vi.fn();
1418
+ const {container} = render(
1419
+ <DateTimeInput
1420
+ label="Meeting"
1421
+ value={'2026-03-15T09:00' as ISODateTimeString}
1422
+ timeOptionInterval={60}
1423
+ onChange={onChange}
1424
+ />,
1425
+ );
1426
+
1427
+ const timeInput = screen.getByLabelText('Meeting time');
1428
+ fireEvent.click(timeInput);
1429
+ fireEvent.change(timeInput, {target: {value: '9:15 AM'}});
1430
+ fireEvent.keyDown(timeInput, {key: 'ArrowDown'});
1431
+ fireEvent.keyDown(timeInput, {key: 'ArrowDown'});
1432
+
1433
+ expect(timeInput).toHaveAttribute(
1434
+ 'aria-activedescendant',
1435
+ optionsIn(container)[11].id,
1436
+ );
1437
+
1438
+ fireEvent.keyDown(timeInput, {key: 'Enter'});
1439
+ expect(onChange.mock.calls[onChange.mock.calls.length - 1][0]).toBe(
1440
+ '2026-03-15T11:00',
1441
+ );
1442
+ });
1443
+
1444
+ it('closes a list that empties underneath it', () => {
1445
+ // The open-time guard cannot help here: the window narrows after the
1446
+ // list is already up. Left open, the keyboard switch is gated off and
1447
+ // the arrows fall back to stepping a value the user cannot see.
1448
+ const onChange = vi.fn();
1449
+ const {container, rerender} = render(
1450
+ <DateTimeInput
1451
+ label="Meeting"
1452
+ value={'2026-03-16T14:00' as ISODateTimeString}
1453
+ min={'2026-03-15T09:10' as ISODateTimeString}
1454
+ max={'2026-03-15T09:20' as ISODateTimeString}
1455
+ timeOptionInterval={60}
1456
+ onChange={onChange}
1457
+ />,
1458
+ );
1459
+
1460
+ const timeInput = screen.getByLabelText('Meeting time');
1461
+ fireEvent.click(timeInput);
1462
+ expect(timeInput).toHaveAttribute('aria-expanded', 'true');
1463
+
1464
+ rerender(
1465
+ <DateTimeInput
1466
+ label="Meeting"
1467
+ value={'2026-03-15T09:15' as ISODateTimeString}
1468
+ min={'2026-03-15T09:10' as ISODateTimeString}
1469
+ max={'2026-03-15T09:20' as ISODateTimeString}
1470
+ timeOptionInterval={60}
1471
+ onChange={onChange}
1472
+ />,
1473
+ );
1474
+
1475
+ expect(optionsIn(container)).toHaveLength(0);
1476
+ expect(timeInput).toHaveAttribute('aria-expanded', 'false');
1477
+ });
1478
+
1479
+ it('closes the list when focus leaves the field entirely', () => {
1480
+ // Light dismiss covers outside clicks and Escape, never a programmatic
1481
+ // focus move. Without this the listbox is orphaned in the top layer.
1482
+ render(
1483
+ <>
1484
+ <DateTimeInput
1485
+ label="Meeting"
1486
+ value={'2026-03-15T14:00' as ISODateTimeString}
1487
+ timeOptionInterval={60}
1488
+ onChange={() => {}}
1489
+ />
1490
+ <button type="button">after</button>
1491
+ </>,
1492
+ );
1493
+
1494
+ const timeInput = screen.getByLabelText('Meeting time');
1495
+ fireEvent.keyDown(timeInput, {key: 'ArrowDown', altKey: true});
1496
+ expect(timeInput).toHaveAttribute('aria-expanded', 'true');
1497
+
1498
+ fireEvent.blur(timeInput, {relatedTarget: getButton('after')});
1499
+ expect(timeInput).toHaveAttribute('aria-expanded', 'false');
1500
+ });
1501
+
1502
+ it('closes an open list when the field goes busy', () => {
1503
+ const {rerender} = render(
1504
+ <DateTimeInput
1505
+ label="Meeting"
1506
+ value={'2026-03-15T09:00' as ISODateTimeString}
1507
+ timeOptionInterval={60}
1508
+ onChange={() => {}}
1509
+ />,
1510
+ );
1511
+
1512
+ const timeInput = screen.getByLabelText('Meeting time');
1513
+ fireEvent.click(timeInput);
1514
+ expect(timeInput).toHaveAttribute('aria-expanded', 'true');
1515
+
1516
+ rerender(
1517
+ <DateTimeInput
1518
+ label="Meeting"
1519
+ value={'2026-03-15T09:00' as ISODateTimeString}
1520
+ timeOptionInterval={60}
1521
+ isLoading
1522
+ onChange={() => {}}
1523
+ />,
1524
+ );
1525
+
1526
+ // An open dropdown over a control that no longer takes input.
1527
+ expect(timeInput).toHaveAttribute('aria-expanded', 'false');
1528
+ });
1529
+
1530
+ it('scales option padding with the field size', () => {
1531
+ const small = render(
1532
+ <DateTimeInput
1533
+ label="Small"
1534
+ value={'2026-03-15T09:00' as ISODateTimeString}
1535
+ timeOptionInterval={60}
1536
+ size="sm"
1537
+ onChange={() => {}}
1538
+ />,
1539
+ );
1540
+ fireEvent.click(screen.getByLabelText('Small time'));
1541
+ const smallClass = optionsIn(small.container)[0].className;
1542
+ small.unmount();
1543
+
1544
+ const large = render(
1545
+ <DateTimeInput
1546
+ label="Large"
1547
+ value={'2026-03-15T09:00' as ISODateTimeString}
1548
+ timeOptionInterval={60}
1549
+ size="lg"
1550
+ onChange={() => {}}
1551
+ />,
1552
+ );
1553
+ fireEvent.click(screen.getByLabelText('Large time'));
1554
+ const largeClass = optionsIn(large.container)[0].className;
1555
+
1556
+ // Rows that ignore size leave an sm field with md-sized options.
1557
+ expect(smallClass).not.toBe(largeClass);
1558
+ });
1559
+
1560
+ it('commits the typed time on Enter, not the option below it', () => {
1561
+ // Typing is free-form, so Enter must not quietly round 1:07 PM down to
1562
+ // the 1:00 PM option the highlight is resting on.
1563
+ const onChange = vi.fn();
1564
+ render(
1565
+ <DateTimeInput
1566
+ label="Meeting"
1567
+ value={'2026-03-15T09:00' as ISODateTimeString}
1568
+ timeOptionInterval={60}
1569
+ onChange={onChange}
1570
+ />,
1571
+ );
1572
+
1573
+ const timeInput = screen.getByLabelText('Meeting time');
1574
+ fireEvent.click(timeInput);
1575
+ fireEvent.change(timeInput, {target: {value: '1:07 PM'}});
1576
+ fireEvent.keyDown(timeInput, {key: 'Enter'});
1577
+
1578
+ const last = onChange.mock.calls[onChange.mock.calls.length - 1][0];
1579
+ expect(last).toBe('2026-03-15T13:07');
1580
+ expect(timeInput).toHaveAttribute('aria-expanded', 'false');
1581
+ });
1582
+
1583
+ it('names the list after timeLabel when one is given', () => {
1584
+ const {container} = render(
1585
+ <DateTimeInput
1586
+ label="Meeting"
1587
+ timeLabel="Start time"
1588
+ value={'2026-03-15T09:00' as ISODateTimeString}
1589
+ timeOptionInterval={60}
1590
+ onChange={() => {}}
1591
+ />,
1592
+ );
1593
+
1594
+ fireEvent.click(screen.getByLabelText('Start time'));
1595
+ // The list belongs to the field the consumer renamed, so it must not
1596
+ // announce itself under the old one.
1597
+ expect(container.querySelector('[role="listbox"]')).toHaveAttribute(
1598
+ 'aria-label',
1599
+ 'Start time options',
1600
+ );
1601
+ });
1602
+
1603
+ it('opens from the keyboard after a pointer press that never became a click', () => {
1604
+ // The show is deferred past an in-flight click so light-dismiss cannot
1605
+ // eat it. A press that ends without a click must not wedge that flag.
1606
+ render(
1607
+ <DateTimeInput
1608
+ label="Meeting"
1609
+ value={'2026-03-15T09:00' as ISODateTimeString}
1610
+ timeOptionInterval={60}
1611
+ onChange={() => {}}
1612
+ />,
1613
+ );
1614
+
1615
+ const timeInput = screen.getByLabelText('Meeting time');
1616
+ fireEvent.pointerDown(timeInput);
1617
+ fireEvent.keyDown(timeInput, {key: 'ArrowDown', altKey: true});
1618
+
1619
+ expect(timeInput).toHaveAttribute('aria-expanded', 'true');
1620
+ });
1621
+
1622
+ it('activates the first option when the field has a date but no time', () => {
1623
+ // The state you land in after picking a date and no time yet. With no
1624
+ // active option, ArrowUp and Enter both did nothing at all.
1625
+ const onChange = vi.fn();
1626
+ const {container} = render(
1627
+ <DateTimeInput
1628
+ label="Meeting"
1629
+ value={'2026-03-15' as ISODateTimeString}
1630
+ timeOptionInterval={60}
1631
+ onChange={onChange}
1632
+ />,
1633
+ );
1634
+
1635
+ const timeInput = screen.getByLabelText('Meeting time');
1636
+ fireEvent.click(timeInput);
1637
+
1638
+ expect(timeInput).toHaveAttribute(
1639
+ 'aria-activedescendant',
1640
+ optionsIn(container)[0].id,
1641
+ );
1642
+
1643
+ // ArrowUp on the first option is a no-op, not a dead end.
1644
+ fireEvent.keyDown(timeInput, {key: 'ArrowUp'});
1645
+ expect(timeInput).toHaveAttribute(
1646
+ 'aria-activedescendant',
1647
+ optionsIn(container)[0].id,
1648
+ );
1649
+
1650
+ fireEvent.keyDown(timeInput, {key: 'Enter'});
1651
+ expect(onChange).toHaveBeenCalledTimes(1);
1652
+ expect(onChange.mock.calls[0][0]).toBe('2026-03-15T00:00');
1653
+ });
1654
+
1655
+ it('keeps the active option valid when the list shrinks while open', () => {
1656
+ const {container, rerender} = render(
1657
+ <DateTimeInput
1658
+ label="Meeting"
1659
+ value={'2026-03-15T14:00' as ISODateTimeString}
1660
+ timeOptionInterval={5}
1661
+ onChange={() => {}}
1662
+ />,
1663
+ );
1664
+
1665
+ const timeInput = screen.getByLabelText('Meeting time');
1666
+ fireEvent.keyDown(timeInput, {key: 'ArrowDown', altKey: true});
1667
+ fireEvent.keyDown(timeInput, {key: 'End'});
1668
+ expect(optionsIn(container)).toHaveLength(288);
1669
+
1670
+ // The cadence widens under the open list, so the old index is far past
1671
+ // the end. A stale index leaves the user with no visible highlight and
1672
+ // arrow keys that appear dead until they walk it back into range.
1673
+ rerender(
1674
+ <DateTimeInput
1675
+ label="Meeting"
1676
+ value={'2026-03-15T14:00' as ISODateTimeString}
1677
+ timeOptionInterval={60}
1678
+ onChange={() => {}}
1679
+ />,
1680
+ );
1681
+
1682
+ const options = optionsIn(container);
1683
+ expect(options).toHaveLength(24);
1684
+ expect(timeInput).toHaveAttribute(
1685
+ 'aria-activedescendant',
1686
+ options[23].id,
1687
+ );
1688
+ });
1689
+
1690
+ it('handles a min/max window that leaves exactly one option', () => {
1691
+ const onChange = vi.fn();
1692
+ const {container} = render(
1693
+ <DateTimeInput
1694
+ label="Meeting"
1695
+ value={'2026-03-15T09:20' as ISODateTimeString}
1696
+ min={'2026-03-15T09:00' as ISODateTimeString}
1697
+ max={'2026-03-15T09:30' as ISODateTimeString}
1698
+ timeOptionInterval={60}
1699
+ onChange={onChange}
1700
+ />,
1701
+ );
1702
+
1703
+ const timeInput = screen.getByLabelText('Meeting time');
1704
+ fireEvent.click(timeInput);
1705
+
1706
+ const options = optionsIn(container);
1707
+ expect(options).toHaveLength(1);
1708
+ expect(options[0].textContent).toBe('9:00 AM');
1709
+
1710
+ // Every clamp expression collapses at length 1.
1711
+ fireEvent.keyDown(timeInput, {key: 'ArrowDown'});
1712
+ fireEvent.keyDown(timeInput, {key: 'ArrowUp'});
1713
+ fireEvent.keyDown(timeInput, {key: 'End'});
1714
+ expect(timeInput).toHaveAttribute('aria-activedescendant', options[0].id);
1715
+
1716
+ fireEvent.keyDown(timeInput, {key: 'Enter'});
1717
+ expect(onChange).toHaveBeenCalledTimes(1);
1718
+ expect(onChange.mock.calls[0][0]).toBe('2026-03-15T09:00');
1719
+ });
1720
+
1721
+ it('labels midnight and noon correctly in 12-hour format', () => {
1722
+ // 00:00 and 12:00 are where 12-hour formatting classically goes wrong.
1723
+ const {container} = render(
1724
+ <DateTimeInput
1725
+ label="Meeting"
1726
+ value={'2026-03-15T14:00' as ISODateTimeString}
1727
+ timeOptionInterval={60}
1728
+ onChange={() => {}}
1729
+ />,
1730
+ );
1731
+
1732
+ fireEvent.click(screen.getByLabelText('Meeting time'));
1733
+ const labels = labelsIn(container);
1734
+ expect(labels[0]).toBe('12:00 AM');
1735
+ expect(labels[12]).toBe('12:00 PM');
1736
+ expect(labels[11]).toBe('11:00 AM');
1737
+ expect(labels[13]).toBe('1:00 PM');
1738
+ });
1739
+
1740
+ it('highlights the nearest earlier option for a value off the cadence', () => {
1741
+ const {container} = render(
1742
+ <DateTimeInput
1743
+ label="Meeting"
1744
+ value={'2026-03-15T14:07' as ISODateTimeString}
1745
+ timeOptionInterval={30}
1746
+ onChange={() => {}}
1747
+ />,
1748
+ );
1749
+
1750
+ const timeInput = screen.getByLabelText('Meeting time');
1751
+ fireEvent.click(timeInput);
1752
+
1753
+ // 14:07 sits between 14:00 (index 28) and 14:30.
1754
+ expect(timeInput).toHaveAttribute(
1755
+ 'aria-activedescendant',
1756
+ optionsIn(container)[28].id,
1757
+ );
1758
+ // ...and nothing claims to be the selected value.
1759
+ expect(
1760
+ optionsIn(container).filter(
1761
+ o => o.getAttribute('aria-selected') === 'true',
1762
+ ),
1763
+ ).toHaveLength(0);
1764
+ });
1765
+
1766
+ it('highlights the first option for a value below the whole range', () => {
1767
+ const {container} = render(
1768
+ <DateTimeInput
1769
+ label="Meeting"
1770
+ value={'2026-03-15T06:00' as ISODateTimeString}
1771
+ min={'2026-03-15T09:00' as ISODateTimeString}
1772
+ timeOptionInterval={60}
1773
+ onChange={() => {}}
1774
+ />,
1775
+ );
1776
+
1777
+ const timeInput = screen.getByLabelText('Meeting time');
1778
+ fireEvent.click(timeInput);
1779
+
1780
+ expect(timeInput).toHaveAttribute(
1781
+ 'aria-activedescendant',
1782
+ optionsIn(container)[0].id,
1783
+ );
1784
+ expect(labelsIn(container)[0]).toBe('9:00 AM');
1785
+ });
1786
+
1787
+ it('clamps the active option at both ends of the list', () => {
1788
+ const {container} = render(
1789
+ <DateTimeInput
1790
+ label="Meeting"
1791
+ value={'2026-03-15T00:00' as ISODateTimeString}
1792
+ timeOptionInterval={60}
1793
+ onChange={() => {}}
1794
+ />,
1795
+ );
1796
+
1797
+ const timeInput = screen.getByLabelText('Meeting time');
1798
+ fireEvent.keyDown(timeInput, {key: 'ArrowDown', altKey: true});
1799
+
1800
+ // Already on the first option: ArrowUp must not wrap to the last.
1801
+ fireEvent.keyDown(timeInput, {key: 'ArrowUp'});
1802
+ expect(timeInput).toHaveAttribute(
1803
+ 'aria-activedescendant',
1804
+ optionsIn(container)[0].id,
1805
+ );
1806
+
1807
+ fireEvent.keyDown(timeInput, {key: 'End'});
1808
+ fireEvent.keyDown(timeInput, {key: 'ArrowDown'});
1809
+ expect(timeInput).toHaveAttribute(
1810
+ 'aria-activedescendant',
1811
+ optionsIn(container)[23].id,
1812
+ );
1813
+ });
1814
+
1815
+ it('leaves Escape alone while the list is closed', () => {
1816
+ // Swallowing Escape here would stop an enclosing Dialog from closing.
1817
+ render(
1818
+ <DateTimeInput
1819
+ label="Meeting"
1820
+ value={'2026-03-15T14:00' as ISODateTimeString}
1821
+ timeOptionInterval={60}
1822
+ onChange={() => {}}
1823
+ />,
1824
+ );
1825
+
1826
+ const timeInput = screen.getByLabelText('Meeting time');
1827
+ // fireEvent returns false when the event was cancelled.
1828
+ expect(fireEvent.keyDown(timeInput, {key: 'Escape'})).toBe(true);
1829
+ });
1830
+
1831
+ it('closes the list on Tab without blocking focus from leaving', () => {
1832
+ render(
1833
+ <DateTimeInput
1834
+ label="Meeting"
1835
+ value={'2026-03-15T14:00' as ISODateTimeString}
1836
+ timeOptionInterval={60}
1837
+ onChange={() => {}}
1838
+ />,
1839
+ );
1840
+
1841
+ const timeInput = screen.getByLabelText('Meeting time');
1842
+ fireEvent.keyDown(timeInput, {key: 'ArrowDown', altKey: true});
1843
+ expect(timeInput).toHaveAttribute('aria-expanded', 'true');
1844
+
1845
+ // Not cancelled, so the browser still moves focus to the next control.
1846
+ expect(fireEvent.keyDown(timeInput, {key: 'Tab'})).toBe(true);
1847
+ expect(timeInput).toHaveAttribute('aria-expanded', 'false');
1848
+ });
1849
+
1850
+ it('picking a time with no date selected changes nothing', () => {
1851
+ // Documents the component's existing contract: every time-commit path
1852
+ // requires a date, so the list cannot invent one.
1853
+ const onChange = vi.fn();
1854
+ const {container} = render(
1855
+ <DateTimeInput
1856
+ label="Meeting"
1857
+ timeOptionInterval={60}
1858
+ onChange={onChange}
1859
+ />,
1860
+ );
1861
+
1862
+ const timeInput = screen.getByLabelText('Meeting time');
1863
+ fireEvent.click(timeInput);
1864
+ expect(timeInput).toHaveAttribute('aria-expanded', 'true');
1865
+
1866
+ fireEvent.click(optionsIn(container)[9]);
1867
+
1868
+ // The click IS handled — the list closes — it just cannot produce a
1869
+ // datetime without a date, so no change is emitted and the field stays
1870
+ // empty rather than showing a time that was never committed.
1871
+ expect(timeInput).toHaveAttribute('aria-expanded', 'false');
1872
+ expect(onChange).not.toHaveBeenCalled();
1873
+ expect(timeInput).toHaveValue('');
1874
+ });
1875
+
1876
+ it('does not open the list while a change is in flight', () => {
1877
+ render(
1878
+ <DateTimeInput
1879
+ label="Meeting"
1880
+ value={'2026-03-15T14:00' as ISODateTimeString}
1881
+ timeOptionInterval={60}
1882
+ isLoading
1883
+ onChange={() => {}}
1884
+ />,
1885
+ );
1886
+
1887
+ const timeInput = screen.getByLabelText('Meeting time');
1888
+ fireEvent.click(timeInput);
1889
+ fireEvent.keyDown(timeInput, {key: 'ArrowDown', altKey: true});
1890
+
1891
+ expect(timeInput).toHaveAttribute('aria-expanded', 'false');
1892
+ });
1893
+
1894
+ it('commits seconds in the value when hasSeconds is set', () => {
1895
+ const onChange = vi.fn();
1896
+ const {container} = render(
1897
+ <DateTimeInput
1898
+ label="Meeting"
1899
+ value={'2026-03-15T14:00:00' as ISODateTimeString}
1900
+ timeOptionInterval={60}
1901
+ hasSeconds
1902
+ onChange={onChange}
1903
+ />,
1904
+ );
1905
+
1906
+ fireEvent.click(screen.getByLabelText('Meeting time'));
1907
+ fireEvent.click(optionsIn(container)[9]);
1908
+
1909
+ expect(onChange).toHaveBeenCalledTimes(1);
1910
+ expect(onChange.mock.calls[0][0]).toBe('2026-03-15T09:00:00');
1911
+ });
1912
+
1913
+ it('keeps two fields on one page independent', () => {
1914
+ const {container} = render(
1915
+ <>
1916
+ <DateTimeInput
1917
+ label="Starts"
1918
+ value={'2026-03-15T09:00' as ISODateTimeString}
1919
+ timeOptionInterval={60}
1920
+ onChange={() => {}}
1921
+ />
1922
+ <DateTimeInput
1923
+ label="Ends"
1924
+ value={'2026-03-15T17:00' as ISODateTimeString}
1925
+ timeOptionInterval={60}
1926
+ onChange={() => {}}
1927
+ />
1928
+ </>,
1929
+ );
1930
+
1931
+ const starts = screen.getByLabelText('Starts time');
1932
+ const ends = screen.getByLabelText('Ends time');
1933
+
1934
+ fireEvent.click(starts);
1935
+ expect(starts).toHaveAttribute('aria-expanded', 'true');
1936
+ expect(ends).toHaveAttribute('aria-expanded', 'false');
1937
+
1938
+ const listboxes = container.querySelectorAll('[role="listbox"]');
1939
+ expect(listboxes).toHaveLength(1);
1940
+ // Option ids must not collide between instances.
1941
+ const ids = optionsIn(container).map(o => o.id);
1942
+ expect(new Set(ids).size).toBe(ids.length);
1943
+ expect(starts.getAttribute('aria-controls')).toBe(listboxes[0].id);
1944
+ });
1945
+
1946
+ it('mounts no option nodes until the list is opened', () => {
1947
+ // The layer renders its children whether open or closed, so an
1948
+ // unconditional list would park a day's worth of divs in the DOM of
1949
+ // every opted-in field. At a 5-minute cadence that is 288 nodes.
1950
+ const {container} = render(
1951
+ <DateTimeInput
1952
+ label="Meeting"
1953
+ value={'2026-03-15T14:00' as ISODateTimeString}
1954
+ timeOptionInterval={5}
1955
+ onChange={() => {}}
1956
+ />,
1957
+ );
1958
+
1959
+ expect(optionsIn(container)).toHaveLength(0);
1960
+ expect(container.querySelector('[role="listbox"]')).toBeNull();
1961
+
1962
+ fireEvent.click(screen.getByLabelText('Meeting time'));
1963
+ expect(optionsIn(container)).toHaveLength(288);
1964
+ });
1965
+
1966
+ it('marks the selected option when the value carries unwanted seconds', () => {
1967
+ // splitDateTime slices the string after the T, so an external value of
1968
+ // 14:00:00 with hasSeconds off leaves valueParts.time as "14:00:00"
1969
+ // while every option is "14:00". A raw equality check silently marks
1970
+ // nothing selected.
1971
+ const {container} = render(
1972
+ <DateTimeInput
1973
+ label="Meeting"
1974
+ value={'2026-03-15T14:00:00' as ISODateTimeString}
1975
+ timeOptionInterval={60}
1976
+ onChange={() => {}}
1977
+ />,
1978
+ );
1979
+
1980
+ fireEvent.click(screen.getByLabelText('Meeting time'));
1981
+ const selected = optionsIn(container).filter(
1982
+ o => o.getAttribute('aria-selected') === 'true',
1983
+ );
1984
+ expect(selected).toHaveLength(1);
1985
+ expect(selected[0].textContent).toBe('2:00 PM');
1986
+ });
1987
+
1988
+ it('keeps focus in the time field when opening evicts the calendar', () => {
1989
+ // Both layers are popover="auto", so showing the time list closes the
1990
+ // calendar. The calendar's onHide returns focus to the date input —
1991
+ // which would rip focus out of the field the user is working in.
1992
+ render(
1993
+ <DateTimeInput
1994
+ label="Meeting"
1995
+ value={'2026-03-15T14:00' as ISODateTimeString}
1996
+ timeOptionInterval={60}
1997
+ onChange={() => {}}
1998
+ />,
1999
+ );
2000
+
2001
+ const dateInput = screen.getAllByRole('combobox')[0];
2002
+ const timeInput = screen.getByLabelText('Meeting time');
2003
+
2004
+ fireEvent.keyDown(dateInput, {key: 'ArrowDown'});
2005
+ expect(dateInput).toHaveAttribute('aria-expanded', 'true');
2006
+
2007
+ timeInput.focus();
2008
+ fireEvent.keyDown(timeInput, {key: 'ArrowDown', altKey: true});
2009
+
2010
+ expect(timeInput).toHaveAttribute('aria-expanded', 'true');
2011
+ expect(timeInput).toHaveFocus();
2012
+ });
2013
+
2014
+ it('starts closed and opens only when asked', () => {
2015
+ render(
2016
+ <DateTimeInput
2017
+ label="Meeting"
2018
+ value={'2026-03-15T14:00' as ISODateTimeString}
2019
+ timeOptionInterval={60}
2020
+ onChange={() => {}}
2021
+ />,
2022
+ );
2023
+
2024
+ const timeInput = screen.getByLabelText('Meeting time');
2025
+ expect(timeInput).toHaveAttribute('aria-expanded', 'false');
2026
+ expect(timeInput).not.toHaveAttribute('aria-activedescendant');
2027
+
2028
+ fireEvent.click(timeInput);
2029
+ expect(timeInput).toHaveAttribute('aria-expanded', 'true');
2030
+ });
2031
+ });
2032
+
945
2033
  describe('weekStartsOn', () => {
946
2034
  // The calendar popover renders in the top layer; jsdom keeps the content in
947
2035
  // the DOM but role queries skip it, so read the columnheaders directly.