@adia-ai/web-components 0.8.40 → 0.8.41

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 (378) hide show
  1. package/CHANGELOG.md +50 -3
  2. package/MIGRATION.md +1042 -16
  3. package/README.md +25 -30
  4. package/USAGE.md +26 -0
  5. package/bin/audit-template-children.mjs +556 -0
  6. package/components/accordion/accordion-item.a2ui.json +4 -0
  7. package/components/accordion/accordion-item.yaml +2 -0
  8. package/components/accordion/accordion.a2ui.json +4 -0
  9. package/components/accordion/accordion.yaml +2 -0
  10. package/components/action-list/action-item.a2ui.json +24 -2
  11. package/components/action-list/action-item.yaml +28 -3
  12. package/components/action-list/action-list.a2ui.json +6 -1
  13. package/components/action-list/action-list.class.js +52 -11
  14. package/components/action-list/action-list.css +43 -3
  15. package/components/action-list/action-list.d.ts +4 -2
  16. package/components/action-list/action-list.yaml +5 -0
  17. package/components/adia-mark/adia-mark.a2ui.json +1 -0
  18. package/components/adia-wordmark/adia-wordmark.a2ui.json +1 -0
  19. package/components/agent-artifact/agent-artifact.a2ui.json +1 -0
  20. package/components/agent-feedback-bar/agent-feedback-bar.a2ui.json +6 -5
  21. package/components/agent-feedback-bar/agent-feedback-bar.yaml +6 -2
  22. package/components/agent-questions/agent-questions.a2ui.json +1 -0
  23. package/components/agent-reasoning/agent-reasoning.a2ui.json +6 -5
  24. package/components/agent-reasoning/agent-reasoning.yaml +6 -2
  25. package/components/agent-suggestions/agent-suggestions.a2ui.json +11 -2
  26. package/components/agent-suggestions/agent-suggestions.class.js +2 -1
  27. package/components/agent-suggestions/agent-suggestions.d.ts +4 -4
  28. package/components/agent-suggestions/agent-suggestions.yaml +4 -2
  29. package/components/agent-trace/agent-trace.a2ui.json +6 -5
  30. package/components/agent-trace/agent-trace.yaml +6 -2
  31. package/components/alert/alert.a2ui.json +1 -0
  32. package/components/alert/alert.css +1 -1
  33. package/components/anchor-bar/anchor-bar.a2ui.json +1 -0
  34. package/components/aside/aside.a2ui.json +1 -0
  35. package/components/avatar/avatar-group.a2ui.json +7 -1
  36. package/components/avatar/avatar-group.yaml +1 -0
  37. package/components/avatar/avatar.a2ui.json +12 -11
  38. package/components/avatar/avatar.d.ts +1 -1
  39. package/components/avatar/avatar.yaml +4 -4
  40. package/components/badge/badge.a2ui.json +1 -0
  41. package/components/block/block.a2ui.json +1 -0
  42. package/components/blockquote/blockquote.a2ui.json +1 -0
  43. package/components/breadcrumb/breadcrumb.a2ui.json +1 -0
  44. package/components/button/button.a2ui.json +1 -3
  45. package/components/button/button.yaml +1 -3
  46. package/components/calendar-grid/calendar-grid.a2ui.json +1 -0
  47. package/components/calendar-grid/calendar-grid.class.js +2 -1
  48. package/components/calendar-picker/calendar-picker.a2ui.json +143 -24
  49. package/components/calendar-picker/calendar-picker.class.js +490 -197
  50. package/components/calendar-picker/calendar-picker.css +120 -171
  51. package/components/calendar-picker/calendar-picker.yaml +225 -47
  52. package/components/canvas/canvas.a2ui.json +6 -5
  53. package/components/canvas/canvas.d.ts +37 -0
  54. package/components/canvas/canvas.js +72 -3
  55. package/components/canvas/canvas.yaml +6 -2
  56. package/components/card/card.a2ui.json +25 -2
  57. package/components/card/card.class.js +3 -0
  58. package/components/card/card.css +31 -1
  59. package/components/card/card.d.ts +2 -2
  60. package/components/card/card.examples.md +3 -3
  61. package/components/card/card.yaml +28 -4
  62. package/components/chart/chart.a2ui.json +9 -4
  63. package/components/chart/chart.class.js +6 -6
  64. package/components/chart/chart.css +6 -4
  65. package/components/chart/chart.d.ts +1 -1
  66. package/components/chart/chart.yaml +5 -3
  67. package/components/chart-legend/chart-legend.a2ui.json +1 -0
  68. package/components/chat-thread/chat-input.a2ui.json +1 -0
  69. package/components/chat-thread/chat-thread.a2ui.json +10 -9
  70. package/components/chat-thread/chat-thread.yaml +13 -20
  71. package/components/check/check.a2ui.json +10 -9
  72. package/components/check/check.yaml +4 -4
  73. package/components/code/code.a2ui.json +1 -0
  74. package/components/col/col.a2ui.json +1 -0
  75. package/components/color-input/color-input.a2ui.json +1 -0
  76. package/components/color-picker/color-picker.a2ui.json +1 -0
  77. package/components/combobox/combobox.a2ui.json +3 -2
  78. package/components/combobox/combobox.yaml +2 -2
  79. package/components/command/command.a2ui.json +12 -10
  80. package/components/command/command.class.js +3 -2
  81. package/components/command/command.yaml +17 -4
  82. package/components/context-menu/context-menu.a2ui.json +1 -0
  83. package/components/date-range-picker/date-range-picker.a2ui.json +2 -1
  84. package/components/date-range-picker/date-range-picker.yaml +1 -1
  85. package/components/datetime-picker/datetime-picker.a2ui.json +15 -108
  86. package/components/datetime-picker/datetime-picker.class.js +37 -690
  87. package/components/datetime-picker/datetime-picker.css +11 -183
  88. package/components/datetime-picker/datetime-picker.d.ts +4 -5
  89. package/components/datetime-picker/datetime-picker.yaml +44 -135
  90. package/components/demo-toggle/demo-toggle.a2ui.json +1 -0
  91. package/components/demo-toggle/demo-toggle.css +1 -1
  92. package/components/description-list/description-list.a2ui.json +1 -0
  93. package/components/display-field/display-field.a2ui.json +1 -0
  94. package/components/divider/divider.a2ui.json +1 -0
  95. package/components/drawer/drawer.a2ui.json +1 -3
  96. package/components/drawer/drawer.css +1 -1
  97. package/components/drawer/drawer.yaml +0 -2
  98. package/components/drilldown/drilldown.a2ui.json +22 -20
  99. package/components/drilldown/drilldown.class.js +4 -3
  100. package/components/drilldown/drilldown.css +6 -6
  101. package/components/drilldown/drilldown.yaml +40 -9
  102. package/components/embed/embed.a2ui.json +6 -5
  103. package/components/embed/embed.yaml +2 -2
  104. package/components/empty-state/empty-state.a2ui.json +1 -0
  105. package/components/feed/feed-item.a2ui.json +25 -5
  106. package/components/feed/feed-item.yaml +28 -7
  107. package/components/feed/feed.a2ui.json +9 -2
  108. package/components/feed/feed.class.js +19 -12
  109. package/components/feed/feed.css +8 -8
  110. package/components/feed/feed.yaml +14 -1
  111. package/components/field/field.a2ui.json +1 -0
  112. package/components/field/field.examples.md +1 -1
  113. package/components/fields/fields.a2ui.json +1 -0
  114. package/components/fields/fields.examples.md +1 -1
  115. package/components/footer/footer.a2ui.json +1 -0
  116. package/components/frame/frame.a2ui.json +1 -0
  117. package/components/grid/grid.a2ui.json +1 -0
  118. package/components/header/header.a2ui.json +3 -2
  119. package/components/header/header.yaml +3 -3
  120. package/components/heatmap/heatmap.a2ui.json +2 -1
  121. package/components/heatmap/heatmap.class.js +1 -1
  122. package/components/heatmap/heatmap.css +4 -4
  123. package/components/heatmap/heatmap.yaml +1 -1
  124. package/components/icon/icon.a2ui.json +1 -0
  125. package/components/image/image.a2ui.json +28 -14
  126. package/components/image/image.class.js +38 -23
  127. package/components/image/image.css +18 -8
  128. package/components/image/image.d.ts +2 -2
  129. package/components/image/image.examples.md +3 -3
  130. package/components/image/image.yaml +23 -7
  131. package/components/inline-edit/inline-edit.a2ui.json +1 -0
  132. package/components/inline-message/inline-message.a2ui.json +3 -1
  133. package/components/inline-message/inline-message.class.js +3 -1
  134. package/components/inline-message/inline-message.d.ts +2 -2
  135. package/components/inline-message/inline-message.yaml +2 -1
  136. package/components/input/input.a2ui.json +66 -16
  137. package/components/input/input.class.js +64 -7
  138. package/components/input/input.css +14 -14
  139. package/components/input/input.d.ts +9 -4
  140. package/components/input/input.yaml +83 -10
  141. package/components/inspector/inspector.a2ui.json +1 -0
  142. package/components/integration-card/integration-card.a2ui.json +1 -0
  143. package/components/kbd/kbd.a2ui.json +1 -0
  144. package/components/link/link.a2ui.json +2 -1
  145. package/components/link/link.yaml +1 -1
  146. package/components/list/list-item.a2ui.json +9 -0
  147. package/components/list/list-item.yaml +12 -0
  148. package/components/list/list.a2ui.json +9 -3
  149. package/components/list/list.class.js +14 -1
  150. package/components/list/list.css +12 -1
  151. package/components/list/list.d.ts +7 -5
  152. package/components/list/list.yaml +16 -6
  153. package/components/list-window/list-window.a2ui.json +1 -0
  154. package/components/list-window/list-window.class.js +2 -1
  155. package/components/loading-overlay/loading-overlay.a2ui.json +1 -0
  156. package/components/mark/mark.a2ui.json +2 -1
  157. package/components/mark/mark.yaml +1 -1
  158. package/components/menu/menu-divider.a2ui.json +1 -0
  159. package/components/menu/menu-item.a2ui.json +3 -1
  160. package/components/menu/menu-item.yaml +6 -0
  161. package/components/menu/menu-label.a2ui.json +1 -0
  162. package/components/menu/menu.a2ui.json +3 -2
  163. package/components/menu/menu.class.js +2 -2
  164. package/components/menu/menu.d.ts +2 -2
  165. package/components/menu/menu.yaml +3 -3
  166. package/components/modal/modal.a2ui.json +1 -0
  167. package/components/nav/nav.a2ui.json +1 -0
  168. package/components/nav/nav.class.js +14 -3
  169. package/components/nav-group/nav-group.a2ui.json +1 -0
  170. package/components/nav-group/nav-group.class.js +3 -1
  171. package/components/nav-item/nav-item.a2ui.json +1 -0
  172. package/components/noodles/noodles.a2ui.json +1 -0
  173. package/components/noodles/noodles.class.js +5 -1
  174. package/components/number-format/number-format.a2ui.json +1 -0
  175. package/components/option-card/option-card.a2ui.json +1 -0
  176. package/components/otp-input/otp-input.a2ui.json +3 -2
  177. package/components/otp-input/otp-input.yaml +9 -2
  178. package/components/page/page.a2ui.json +13 -0
  179. package/components/page/page.d.ts +1 -1
  180. package/components/page/page.yaml +1 -0
  181. package/components/pagination/pagination.a2ui.json +1 -0
  182. package/components/pagination/pagination.class.js +3 -2
  183. package/components/pane/pane.a2ui.json +6 -0
  184. package/components/pane/pane.class.js +3 -3
  185. package/components/pane/pane.css +2 -2
  186. package/components/pane/pane.yaml +3 -0
  187. package/components/password-strength/password-strength.a2ui.json +1 -0
  188. package/components/pipeline-status/pipeline-status.a2ui.json +1 -0
  189. package/components/popover/popover.a2ui.json +7 -6
  190. package/components/popover/popover.class.js +2 -2
  191. package/components/popover/popover.d.ts +2 -2
  192. package/components/popover/popover.yaml +4 -4
  193. package/components/preview/preview.a2ui.json +1 -0
  194. package/components/preview/preview.class.js +5 -1
  195. package/components/progress/progress.a2ui.json +47 -24
  196. package/components/progress/progress.class.js +66 -11
  197. package/components/progress/progress.css +44 -26
  198. package/components/progress/progress.d.ts +7 -5
  199. package/components/progress/progress.examples.md +2 -2
  200. package/components/progress/progress.yaml +235 -53
  201. package/components/progress-row/progress-row.a2ui.json +16 -12
  202. package/components/progress-row/progress-row.d.ts +2 -2
  203. package/components/progress-row/progress-row.examples.md +2 -14
  204. package/components/progress-row/progress-row.yaml +28 -36
  205. package/components/qr-code/qr-code.a2ui.json +3 -2
  206. package/components/qr-code/qr-code.class.js +4 -4
  207. package/components/qr-code/qr-code.css +1 -1
  208. package/components/qr-code/qr-code.yaml +5 -3
  209. package/components/radio/radio.a2ui.json +8 -7
  210. package/components/radio/radio.class.js +8 -0
  211. package/components/radio/radio.yaml +3 -3
  212. package/components/radio-group/radio-group.a2ui.json +5 -3
  213. package/components/radio-group/radio-group.class.js +11 -0
  214. package/components/radio-group/radio-group.yaml +7 -1
  215. package/components/range/range.a2ui.json +6 -0
  216. package/components/range/range.class.js +2 -2
  217. package/components/range/range.css +2 -2
  218. package/components/range/range.yaml +3 -0
  219. package/components/rating/rating.a2ui.json +1 -0
  220. package/components/relative-time/relative-time.a2ui.json +1 -0
  221. package/components/richtext/richtext.a2ui.json +2 -1
  222. package/components/richtext/richtext.yaml +1 -1
  223. package/components/row/row.a2ui.json +1 -0
  224. package/components/search/search.a2ui.json +1 -0
  225. package/components/search/search.class.js +4 -1
  226. package/components/section/section.a2ui.json +1 -0
  227. package/components/segment/segment.a2ui.json +21 -10
  228. package/components/segment/segment.css +15 -0
  229. package/components/segment/segment.d.ts +2 -1
  230. package/components/segment/segment.yaml +20 -13
  231. package/components/segmented/segmented.a2ui.json +36 -7
  232. package/components/segmented/segmented.class.js +113 -22
  233. package/components/segmented/segmented.css +3 -0
  234. package/components/segmented/segmented.d.ts +3 -1
  235. package/components/segmented/segmented.yaml +80 -8
  236. package/components/select/select.a2ui.json +18 -13
  237. package/components/select/select.class.js +61 -37
  238. package/components/select/select.css +15 -13
  239. package/components/select/select.yaml +18 -7
  240. package/components/skeleton/skeleton.a2ui.json +8 -7
  241. package/components/skeleton/skeleton.class.js +8 -7
  242. package/components/skeleton/skeleton.css +21 -6
  243. package/components/skeleton/skeleton.d.ts +2 -2
  244. package/components/skeleton/skeleton.examples.md +2 -2
  245. package/components/skeleton/skeleton.yaml +3 -2
  246. package/components/skip-nav/skip-nav.a2ui.json +1 -0
  247. package/components/slider/slider.a2ui.json +6 -0
  248. package/components/slider/slider.class.js +20 -3
  249. package/components/slider/slider.css +2 -2
  250. package/components/slider/slider.yaml +3 -0
  251. package/components/spinner/spinner.a2ui.json +1 -0
  252. package/components/stack/stack.a2ui.json +1 -0
  253. package/components/stat/stat.a2ui.json +1 -0
  254. package/components/stat/stat.js +2 -2
  255. package/components/step-progress/step-progress.a2ui.json +1 -0
  256. package/components/step-progress/step-progress.yaml +1 -1
  257. package/components/stepper/stepper-item.a2ui.json +7 -3
  258. package/components/stepper/stepper-item.yaml +15 -5
  259. package/components/stepper/stepper.a2ui.json +5 -1
  260. package/components/stepper/stepper.class.js +31 -16
  261. package/components/stepper/stepper.d.ts +1 -1
  262. package/components/stepper/stepper.yaml +4 -2
  263. package/components/stream/stream.a2ui.json +1 -0
  264. package/components/swatch/swatch.a2ui.json +1 -0
  265. package/components/swiper/swiper.a2ui.json +1 -0
  266. package/components/swiper/swiper.class.js +5 -1
  267. package/components/switch/switch.a2ui.json +12 -11
  268. package/components/switch/switch.css +1 -1
  269. package/components/switch/switch.yaml +6 -6
  270. package/components/table/table.a2ui.json +1 -0
  271. package/components/table/table.class.js +14 -12
  272. package/components/table/table.css +11 -9
  273. package/components/table-toolbar/table-toolbar.a2ui.json +1 -0
  274. package/components/table-toolbar/table-toolbar.class.js +38 -19
  275. package/components/table-toolbar/table-toolbar.css +1 -1
  276. package/components/tabs/tab.a2ui.json +9 -0
  277. package/components/tabs/tab.js +4 -0
  278. package/components/tabs/tab.yaml +15 -0
  279. package/components/tabs/tabs.a2ui.json +9 -4
  280. package/components/tabs/tabs.class.js +13 -1
  281. package/components/tabs/tabs.css +8 -0
  282. package/components/tabs/tabs.d.ts +2 -0
  283. package/components/tabs/tabs.yaml +9 -2
  284. package/components/tag/tag.a2ui.json +1 -0
  285. package/components/tags-input/tags-input.a2ui.json +22 -17
  286. package/components/tags-input/tags-input.class.js +13 -11
  287. package/components/tags-input/tags-input.css +10 -6
  288. package/components/tags-input/tags-input.d.ts +16 -7
  289. package/components/tags-input/tags-input.examples.md +2 -2
  290. package/components/tags-input/tags-input.yaml +25 -8
  291. package/components/text/text.a2ui.json +6 -5
  292. package/components/text/text.css +10 -5
  293. package/components/text/text.d.ts +2 -2
  294. package/components/text/text.yaml +9 -4
  295. package/components/textarea/textarea.a2ui.json +2 -1
  296. package/components/textarea/textarea.yaml +1 -1
  297. package/components/theme-provider/theme-provider.a2ui.json +2 -1
  298. package/components/theme-provider/theme-provider.d.ts +5 -3
  299. package/components/theme-provider/theme-provider.yaml +5 -3
  300. package/components/time-picker/time-picker.a2ui.json +1 -0
  301. package/components/time-picker/time-picker.class.js +4 -2
  302. package/components/timeline/timeline-item.a2ui.json +26 -3
  303. package/components/timeline/timeline-item.yaml +22 -7
  304. package/components/timeline/timeline.a2ui.json +4 -0
  305. package/components/timeline/timeline.class.js +29 -18
  306. package/components/timeline/timeline.d.ts +3 -3
  307. package/components/timeline/timeline.yaml +2 -0
  308. package/components/toast/toast.a2ui.json +10 -3
  309. package/components/toast/toast.yaml +20 -13
  310. package/components/toc/toc.a2ui.json +1 -0
  311. package/components/toggle-group/toggle-group.a2ui.json +7 -3
  312. package/components/toggle-group/toggle-group.class.js +40 -1
  313. package/components/toggle-group/toggle-group.css +6 -1
  314. package/components/toggle-group/toggle-group.d.ts +2 -2
  315. package/components/toggle-group/toggle-group.yaml +21 -12
  316. package/components/toggle-group/toggle-option.a2ui.json +7 -3
  317. package/components/toggle-group/toggle-option.yaml +13 -7
  318. package/components/toggle-scheme/toggle-scheme.a2ui.json +2 -1
  319. package/components/toggle-scheme/toggle-scheme.yaml +1 -1
  320. package/components/toolbar/toolbar-group.a2ui.json +1 -0
  321. package/components/toolbar/toolbar.a2ui.json +1 -0
  322. package/components/toolbar/toolbar.class.js +3 -0
  323. package/components/tooltip/tooltip.a2ui.json +1 -0
  324. package/components/tour/tour-step.a2ui.json +4 -0
  325. package/components/tour/tour-step.yaml +2 -0
  326. package/components/tour/tour.a2ui.json +4 -0
  327. package/components/tour/tour.yaml +2 -0
  328. package/components/tree/tree-item.a2ui.json +5 -0
  329. package/components/tree/tree-item.yaml +3 -0
  330. package/components/tree/tree.a2ui.json +4 -0
  331. package/components/tree/tree.class.js +2 -1
  332. package/components/tree/tree.yaml +2 -0
  333. package/components/upload/upload.a2ui.json +8 -1
  334. package/components/upload/upload.class.js +35 -5
  335. package/components/upload/upload.css +28 -0
  336. package/components/upload/upload.d.ts +16 -0
  337. package/components/upload/upload.yaml +27 -7
  338. package/components/visually-hidden/visually-hidden.a2ui.json +1 -0
  339. package/core/dom.d.ts +11 -0
  340. package/core/dom.js +34 -0
  341. package/core/form.js +7 -0
  342. package/core/index.d.ts +1 -0
  343. package/core/index.js +1 -0
  344. package/core/responsive.js +16 -4
  345. package/core/template.js +2 -1
  346. package/custom-elements.json +437 -489
  347. package/dist/host.min.css +1 -1
  348. package/dist/host.sheet.js +1 -1
  349. package/dist/theme-provider.min.js +5 -5
  350. package/dist/web-components.min.css +1 -1
  351. package/dist/web-components.min.js +91 -104
  352. package/dist/web-components.sheet.js +1 -1
  353. package/package.json +4 -2
  354. package/patterns/conversion-funnel/conversion-funnel.examples.html +15 -15
  355. package/patterns/inline-dialog/inline-dialog.examples.html +2 -2
  356. package/patterns/inline-dialog/inline-dialog.html +4 -0
  357. package/patterns/timezone-selector/timezone-selector.examples.html +1 -1
  358. package/patterns/timezone-selector/timezone-selector.html +2 -2
  359. package/styles/api/text.css +7 -0
  360. package/traits/_catalog.json +129 -41
  361. package/traits/anchor-positioning/anchor-positioning.js +1 -0
  362. package/traits/announcer/announcer.js +1 -0
  363. package/traits/arrow-grid-nav/arrow-grid-nav.js +1 -0
  364. package/traits/attention-pulse/attention-pulse.js +1 -0
  365. package/traits/count-up/count-up.js +1 -0
  366. package/traits/define.js +78 -0
  367. package/traits/focus-restore/focus-restore.js +2 -1
  368. package/traits/haptic-feedback/haptic-feedback.js +1 -0
  369. package/traits/input-mask/input-mask.js +1 -0
  370. package/traits/intersection-observer/intersection-observer.js +1 -0
  371. package/traits/layout-animation/layout-animation.js +1 -0
  372. package/traits/noise-texture/noise-texture.js +1 -0
  373. package/traits/resize-observer/resize-observer.js +1 -0
  374. package/traits/scroll-progress/scroll-progress.js +1 -0
  375. package/traits/snap-to-grid/snap-to-grid.js +1 -0
  376. package/traits/sound-feedback/sound-feedback.js +1 -0
  377. package/traits/spring-animate/spring-animate.js +1 -0
  378. package/traits/validation/validation.js +1 -0
@@ -12,32 +12,66 @@
12
12
  */
13
13
 
14
14
  /**
15
- * <calendar-picker-ui label="Date" placeholder="Select date..." format="short"></calendar-picker-ui>
15
+ * <calendar-picker-ui placeholder="Select date..." format="short"></calendar-picker-ui>
16
16
  *
17
- * Date picker with trigger button and popover calendar grid.
18
- * Form-associated via UIFormElement + ElementInternals.
17
+ * Date (and, per `[precision]`, date+time) picker with trigger button and
18
+ * popover panel. Form-associated via UIFormElement + ElementInternals.
19
+ *
20
+ * gh#1372 (#1363 C3c) — this class now folds `<datetime-picker-ui>`'s surface
21
+ * in behind a widened `precision` axis (`date` | `minute` | `second`) rather
22
+ * than the two primitives duplicating the same trigger/popover/anchor/focus-
23
+ * trap machinery. `precision="date"` (the default) is exactly the prior
24
+ * single-pane behavior — composes only `<calendar-grid-ui>`. `precision`
25
+ * `"minute"`/`"second"` additionally composes `<divider-ui>` +
26
+ * `<time-picker-ui>` as a second pane, `value` becomes a full ISO 8601
27
+ * datetime string, and `hour-cycle`/`step`/`locale` become active — the same
28
+ * contract `<datetime-picker-ui>` shipped, which is now a deprecated thin
29
+ * alias subclass (see datetime-picker.class.js) mapping onto this class with
30
+ * `precision="minute"` by default. Per ADR-0027, the consumer must
31
+ * explicitly import every composed primitive (see `composes:` in
32
+ * calendar-picker.yaml) — this class doesn't auto-import them.
33
+ *
34
+ * #1363 C3d sibling-contract convergence, folded into this same build:
35
+ * - `readonly` (inherited from UIFormElement) now has real behavior —
36
+ * blocks the popover from opening; keyboard focus/inspection still
37
+ * works.
38
+ * - `input` / `open` / `close` / `invalid` events now fire (previously
39
+ * only `change` existed).
40
+ * - `name` / `min` / `max` are declared `reflect: true` in the yaml,
41
+ * matching the class's actual (already-reflecting) behavior.
42
+ * - The odd first-class `label` prop is REMOVED — siblings all delegate
43
+ * the label to `<field-ui label>`; this was calendar-picker's own
44
+ * outlier. Breaking; migration-guide entry + consumer scan accompany
45
+ * this change.
19
46
  *
20
47
  * Properties:
21
- * label field label (rendered via CSS ::before)
22
- * placeholder — trigger text when no date selected
48
+ * placeholder trigger text when no value selected
23
49
  * format — display format: short | long | iso
24
- * min minimum selectable date (ISO string)
25
- * maxmaximum selectable date (ISO string)
50
+ * precision date | minute | second (mode axis — widens the surface)
51
+ * minminimum selectable date/datetime (ISO string)
52
+ * max — maximum selectable date/datetime (ISO string)
53
+ * step — per-Arrow-keypress time increment in seconds (time modes only)
54
+ * hourcycle — '' | h12 | h23 (time modes only; attribute hour-cycle)
55
+ * locale — BCP-47 locale for hour-cycle derivation (time modes only)
26
56
  * open — popover visibility
57
+ * readonly — blocks edits; keyboard inspection still works
27
58
  *
28
- * Keyboard: Arrow keys navigate days, Enter selects, Escape closes.
59
+ * Keyboard: Enter/Space (closed) opens; Escape (open) closes. Arrow keys,
60
+ * PageUp/PageDown, Home/End, and day-selection Enter/Space are handled by
61
+ * the composed calendar-grid-ui once DOM focus moves into it on open (see
62
+ * the queueMicrotask in render()) — calendar-grid-ui binds its own keydown
63
+ * listener to itself, not to this host, so focus must be inside it for
64
+ * that handling to receive events at all. In a time-pane mode, Tab cycles
65
+ * from the calendar pane into the time pane's Spinbutton segments.
29
66
  */
30
67
 
31
68
  import { UIFormElement } from '../../core/form.js';
32
69
  import { anchorPopover } from '../../core/anchor.js';
33
70
 
34
- const DAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
35
71
  const MONTHS_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
36
72
  const MONTHS_LONG = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
37
73
 
38
- function toISO(y, m, d) {
39
- return `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
40
- }
74
+ function pad2(n) { return String(n).padStart(2, '0'); }
41
75
 
42
76
  function parseISO(str) {
43
77
  if (!str) return null;
@@ -46,68 +80,276 @@ function parseISO(str) {
46
80
  return new Date(y, m - 1, d);
47
81
  }
48
82
 
49
- function sameDay(a, b) {
50
- return a.getFullYear() === b.getFullYear() &&
51
- a.getMonth() === b.getMonth() &&
52
- a.getDate() === b.getDate();
83
+ /** Parse "YYYY-MM-DDTHH:mm" or "YYYY-MM-DDTHH:mm:ss" → {date, time} or null. */
84
+ function parseDatetime(str) {
85
+ if (!str) return null;
86
+ const s = String(str).trim();
87
+ // Tolerate space separator as a Generative-UI quality-of-life affordance;
88
+ // canonical wire form is always "T".
89
+ const m = /^(\d{4}-\d{2}-\d{2})[T ](\d{1,2}:\d{2}(?::\d{2})?)$/.exec(s);
90
+ if (!m) return null;
91
+ return { date: m[1], time: m[2] };
92
+ }
93
+
94
+ /** Compose canonical "YYYY-MM-DDTHH:mm[:ss]" from a {date, time} pair. */
95
+ function formatDatetime(date, time, precision) {
96
+ if (!date || !time) return '';
97
+ const tm = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(time);
98
+ if (!tm) return '';
99
+ const hh = pad2(Number(tm[1]));
100
+ const mm = tm[2];
101
+ const ss = tm[3] || '00';
102
+ return precision === 'second' ? `${date}T${hh}:${mm}:${ss}` : `${date}T${hh}:${mm}`;
103
+ }
104
+
105
+ function formatDatePart(iso, fmt) {
106
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso || '');
107
+ if (!m) return '';
108
+ const y = +m[1];
109
+ const mo = +m[2] - 1;
110
+ const d = +m[3];
111
+ switch (fmt) {
112
+ case 'long': return `${MONTHS_LONG[mo]} ${d}, ${y}`;
113
+ case 'iso': return iso;
114
+ case 'short':
115
+ default: return `${MONTHS_SHORT[mo]} ${d}, ${y}`;
116
+ }
117
+ }
118
+
119
+ function formatTimePart(time, precision, hourcycle) {
120
+ const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(time || '');
121
+ if (!m) return '';
122
+ const h = +m[1];
123
+ const mm = m[2];
124
+ const ss = m[3];
125
+ let hStr;
126
+ let meridiem = '';
127
+ if (hourcycle === 'h12') {
128
+ const isPM = h >= 12;
129
+ const dispH = (h % 12) || 12;
130
+ hStr = pad2(dispH);
131
+ meridiem = isPM ? ' PM' : ' AM';
132
+ } else {
133
+ hStr = pad2(h);
134
+ }
135
+ const sec = (precision === 'second' && ss != null) ? `:${ss}` : '';
136
+ return `${hStr}:${mm}${sec}${meridiem}`;
53
137
  }
54
138
 
55
139
  export class UICalendarPicker extends UIFormElement {
56
- static labelDeprecated = false; // §170 (v0.5.4): label is first-class per calendar-picker.yaml
140
+ // §170 (v0.5.4)/gh#1372: label was first-class; removed per #1363 C3d —
141
+ // siblings all delegate to <field-ui label>. Base class still reads this
142
+ // static to decide whether to warn on an authored [label] attribute.
143
+ static labelDeprecated = true;
144
+
57
145
  // §154 (v0.5.3): Phosphor icons this primitive auto-stamps (without
58
146
  // consumer markup). Aggregated by installIconLoadersForRegistered()
59
147
  // across all defined elements. Audited by check-required-icons.mjs
60
148
  // (slot 11). Per FEEDBACK-06 §4 + FEEDBACK-07 §4.
61
- static requiredIcons = ['calendar', 'caret-left', 'caret-right'];
149
+ // caret-left/caret-right dropped (gh#1371) — those nav-button icons are
150
+ // now stamped by the composed calendar-grid-ui, which declares its own
151
+ // requiredIcons; this class itself only stamps `calendar` on the trigger.
152
+ static requiredIcons = ['calendar'];
62
153
 
63
154
  static properties = {
64
155
  ...UIFormElement.properties,
65
- label: { type: String, default: '', reflect: true },
66
- placeholder: { type: String, default: 'Select date...', reflect: true },
67
- format: { type: String, default: 'short', reflect: true },
68
- min: { type: String, default: '', reflect: true },
69
- max: { type: String, default: '', reflect: true },
70
- open: { type: Boolean, default: false, reflect: true },
156
+ placeholder: { type: String, default: 'Select date...', reflect: true },
157
+ format: { type: String, default: 'short', reflect: true },
158
+ min: { type: String, default: '', reflect: true },
159
+ max: { type: String, default: '', reflect: true },
160
+ open: { type: Boolean, default: false, reflect: true },
161
+ // gh#1372 (#1363 C3c) the widened mode axis. 'date' is the prior
162
+ // single-pane behavior; 'minute'/'second' fold in what
163
+ // <datetime-picker-ui> used to be its own primitive for.
164
+ precision: { type: String, default: 'date', reflect: true },
165
+ // Active only when precision !== 'date' — mirrors datetime-picker's
166
+ // former contract exactly (folded in, not reinvented).
167
+ step: { type: Number, default: 60, reflect: true },
168
+ hourcycle: { type: String, default: '', reflect: true, attribute: 'hour-cycle' },
169
+ locale: { type: String, default: '', reflect: false },
71
170
  // Popover placement — yaml documented this as reflect:true since v1;
72
171
  // the JS reads it via getAttribute('placement') in the anchorPopover
73
172
  // call site. Declaring here so el.placement = 'top' also updates.
74
- placement: { type: String, default: 'bottom', reflect: true },
173
+ placement: { type: String, default: 'bottom', reflect: true },
75
174
  };
76
175
 
77
176
  static template = () => null;
78
177
 
79
178
  #popover = null;
80
179
  #trigger = null;
180
+ #grid = null;
181
+ #gridBound = false;
182
+ #divider = null;
183
+ #timePane = null;
184
+ #timePaneBound = false;
81
185
  #anchorCleanup = null;
82
- #viewYear = new Date().getFullYear();
83
- #viewMonth = new Date().getMonth();
84
- #focusedDay = null;
85
- #popoverClickBound = false;
86
186
  #openRaf = null;
187
+ // Tracks whether the popover was already open as of the LAST render, so
188
+ // the initial-focus microtask below fires only on the closed→open
189
+ // transition — not on every subsequent open-state render (e.g. the one
190
+ // #onGridChange triggers by writing `this.value`), which would otherwise
191
+ // re-steal focus back to the first day on every selection (review finding,
192
+ // PR #1437). Reset on close (naturally, via the render() assignment below)
193
+ // and explicitly on disconnect so a later reconnect-while-open re-arms it.
194
+ #wasOpen = false;
195
+ // Partial {date, time} selection state — only meaningful when
196
+ // precision !== 'date' (two panes must both commit before `value` is
197
+ // written). Mirrors the former datetime-picker.class.js `#pending`.
198
+ #pending = null;
199
+ // Records why the NEXT open/close transition happened, read once by
200
+ // render()'s wasOpen-diff below, then cleared. Centralizing the
201
+ // open/close event dispatch in render() (rather than at every call site
202
+ // that flips `this.open`) means no call site can forget to fire it.
203
+ #openTrigger = 'programmatic';
204
+ #closeReason = 'programmatic';
205
+
206
+ get #hasTimePane() { return this.precision === 'minute' || this.precision === 'second'; }
207
+
208
+ // Substrate change → adopt the selection, mirror it back out as this
209
+ // primitive's own `change` (same detail shape callers already expect),
210
+ // and stop the inner event from also reaching listeners upstream of us
211
+ // (mirrors date-range-picker's #onDaySelect — one `change`, not two).
212
+ #onGridChange = (e) => {
213
+ if (this.readonly) return;
214
+ e.stopPropagation();
215
+ const iso = e.detail?.value || this.#grid?.value || '';
216
+ if (!iso) return;
217
+ if (!this.#hasTimePane) {
218
+ this.value = iso;
219
+ this.syncValue(iso);
220
+ this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
221
+ // Popover stays open until the user clicks outside — matches the prior
222
+ // inlined-grid behavior (no auto-close on selection).
223
+ return;
224
+ }
225
+ // Two-pane (datetime) mode — date half of a pending commit.
226
+ if (!this.#pending) this.#pending = { date: '', time: '' };
227
+ this.#pending.date = iso;
228
+ const existing = parseDatetime(this.value);
229
+ const timeCandidate = this.#pending.time || existing?.time || '';
230
+ const partial = timeCandidate ? formatDatetime(iso, timeCandidate, this.precision) : `${iso}T`;
231
+ this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: partial } }));
232
+ if (timeCandidate) {
233
+ this.#commitFromParts({ date: iso, time: timeCandidate });
234
+ } else {
235
+ // Advance focus to the time pane's first segment — a11y model:
236
+ // date → time → commit.
237
+ queueMicrotask(() => {
238
+ if (!this.open) return;
239
+ const hourSeg = this.#timePane?.querySelector?.('[data-segment="hour"]');
240
+ hourSeg?.focus?.();
241
+ });
242
+ }
243
+ };
87
244
 
88
- #onPopoverClick = (e) => {
89
- const target = e.target instanceof Element ? e.target : null;
90
- if (!target) return;
91
- const prev = target.closest('[data-cal-prev]');
92
- if (prev) { e.stopPropagation(); this.#navigate(-1); return; }
93
- const next = target.closest('[data-cal-next]');
94
- if (next) { e.stopPropagation(); this.#navigate(1); return; }
95
- const day = target.closest('[data-cal-day][data-date]:not([disabled])');
96
- if (day) { e.stopPropagation(); this.#selectDate(day.dataset.date); }
245
+ #onTimePaneChange = (e) => {
246
+ if (this.readonly) return;
247
+ e.stopPropagation();
248
+ const time = e.detail?.value || this.#timePane?.value || '';
249
+ if (!time) return;
250
+ if (!this.#pending) this.#pending = { date: '', time: '' };
251
+ this.#pending.time = time;
252
+ const existing = parseDatetime(this.value);
253
+ const dateCandidate = this.#pending.date || existing?.date || '';
254
+ if (dateCandidate) {
255
+ this.#commitFromParts({ date: dateCandidate, time });
256
+ } else {
257
+ this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: `T${time}` } }));
258
+ }
259
+ };
260
+
261
+ #onTimePaneInput = (e) => {
262
+ if (this.readonly) return;
263
+ e.stopPropagation();
264
+ const time = e.detail?.value || '';
265
+ const existing = parseDatetime(this.value);
266
+ const date = this.#pending?.date || existing?.date || '';
267
+ const partial = (date && time) ? formatDatetime(date, time, this.precision) : (time ? `T${time}` : '');
268
+ this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: partial } }));
97
269
  };
98
270
 
271
+ #onPaneInvalid = (e) => {
272
+ e.stopPropagation();
273
+ this.dispatchEvent(new CustomEvent('invalid', { bubbles: true, detail: { value: this.value, reason: e.detail?.reason || 'pane' } }));
274
+ };
275
+
276
+ /** Commit a candidate {date, time} pair once both halves are present. */
277
+ #commitFromParts(parts) {
278
+ if (!parts || !parts.date || !parts.time) return;
279
+ const next = formatDatetime(parts.date, parts.time, this.precision);
280
+ if (!next) {
281
+ this.dispatchEvent(new CustomEvent('invalid', { bubbles: true, detail: { value: next, reason: 'parse' } }));
282
+ return;
283
+ }
284
+ if (this.min && next < this.min) {
285
+ this.dispatchEvent(new CustomEvent('invalid', { bubbles: true, detail: { value: next, reason: 'below-min' } }));
286
+ return;
287
+ }
288
+ if (this.max && next > this.max) {
289
+ this.dispatchEvent(new CustomEvent('invalid', { bubbles: true, detail: { value: next, reason: 'above-max' } }));
290
+ return;
291
+ }
292
+ this.value = next;
293
+ this.#pending = null;
294
+ this.syncValue(next);
295
+ this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: next } }));
296
+ }
297
+
298
+ /**
299
+ * Override `syncValue` to run date/datetime constraint checks in addition
300
+ * to the base UIFormElement::syncValue write.
301
+ */
302
+ syncValue(val) {
303
+ const baseVal = val ?? this.value ?? '';
304
+ this.internals.setFormValue(baseVal);
305
+ this.#runConstraints(baseVal);
306
+ }
307
+
308
+ #runConstraints(val) {
309
+ if (this.required && !val) {
310
+ this.internals.setValidity(
311
+ { valueMissing: true },
312
+ this.getAttribute('data-msg-required') || (this.#hasTimePane ? 'Please select a date and time.' : 'Please select a date.'),
313
+ this,
314
+ );
315
+ return false;
316
+ }
317
+ if (!val) {
318
+ this.internals.setValidity({});
319
+ return true;
320
+ }
321
+ if (this.#hasTimePane) {
322
+ const parsed = parseDatetime(val);
323
+ if (!parsed) {
324
+ this.internals.setValidity({ badInput: true }, 'Invalid date or time format.', this);
325
+ return false;
326
+ }
327
+ }
328
+ if (this.min && val < this.min) {
329
+ this.internals.setValidity(
330
+ { rangeUnderflow: true },
331
+ `Earliest selectable ${this.#hasTimePane ? 'datetime' : 'date'} is ${this.min}.`,
332
+ this,
333
+ );
334
+ return false;
335
+ }
336
+ if (this.max && val > this.max) {
337
+ this.internals.setValidity(
338
+ { rangeOverflow: true },
339
+ `Latest selectable ${this.#hasTimePane ? 'datetime' : 'date'} is ${this.max}.`,
340
+ this,
341
+ );
342
+ return false;
343
+ }
344
+ this.internals.setValidity({});
345
+ return true;
346
+ }
347
+
99
348
  connected() {
100
349
  super.connected();
101
350
  this.setAttribute('role', 'group');
102
351
  this.addEventListener('click', this.#onClick);
103
352
  this.addEventListener('keydown', this.#onKey);
104
-
105
- // Initialize view to selected date or today
106
- const sel = parseISO(this.value);
107
- if (sel) {
108
- this.#viewYear = sel.getFullYear();
109
- this.#viewMonth = sel.getMonth();
110
- }
111
353
  }
112
354
 
113
355
  render() {
@@ -125,8 +367,6 @@ export class UICalendarPicker extends UIFormElement {
125
367
  this.#popover.setAttribute('slot', 'popover');
126
368
  this.#popover.setAttribute('popover', 'manual');
127
369
  this.appendChild(this.#popover);
128
- this.#popover.addEventListener('click', this.#onPopoverClick);
129
- this.#popoverClickBound = true;
130
370
  } else {
131
371
  const display = this.querySelector('[slot="display"]');
132
372
  if (display) display.textContent = this.#displayText();
@@ -134,17 +374,120 @@ export class UICalendarPicker extends UIFormElement {
134
374
 
135
375
  if (!this.#popover) this.#popover = this.querySelector('[slot="popover"]');
136
376
  if (!this.#trigger) this.#trigger = this.querySelector('[slot="trigger"]');
137
- if (this.#popover && !this.#popoverClickBound) {
138
- this.#popover.addEventListener('click', this.#onPopoverClick);
139
- this.#popoverClickBound = true;
377
+
378
+ // Ensure the substrate grid exists inside the popover — composed once,
379
+ // reused across opens (matches date-range-picker's calFrom/calTo parts).
380
+ if (this.#popover && !this.#grid) {
381
+ this.#grid = this.#popover.querySelector('calendar-grid-ui');
382
+ if (!this.#grid) {
383
+ this.#grid = document.createElement('calendar-grid-ui');
384
+ this.#popover.appendChild(this.#grid);
385
+ }
386
+ }
387
+ if (this.#grid && !this.#gridBound) {
388
+ this.#grid.addEventListener('change', this.#onGridChange);
389
+ this.#gridBound = true;
390
+ }
391
+
392
+ // gh#1372 — stamp/tear down the time pane + divider as `precision`
393
+ // dictates. Idempotent: a no-op once the pane already matches.
394
+ if (this.#popover) {
395
+ if (this.#hasTimePane) {
396
+ if (!this.#divider) {
397
+ this.#divider = this.#popover.querySelector('[data-pane-divider]');
398
+ if (!this.#divider) {
399
+ this.#divider = document.createElement('divider-ui');
400
+ this.#divider.setAttribute('data-pane-divider', '');
401
+ this.#divider.setAttribute('vertical', '');
402
+ this.#popover.appendChild(this.#divider);
403
+ }
404
+ }
405
+ if (!this.#timePane) {
406
+ this.#timePane = this.#popover.querySelector('[data-time-pane]');
407
+ if (!this.#timePane) {
408
+ this.#timePane = document.createElement('time-picker-ui');
409
+ this.#timePane.setAttribute('data-time-pane', '');
410
+ this.#popover.appendChild(this.#timePane);
411
+ }
412
+ }
413
+ if (!this.#timePaneBound) {
414
+ this.#timePane.addEventListener('change', this.#onTimePaneChange);
415
+ this.#timePane.addEventListener('input', this.#onTimePaneInput);
416
+ this.#timePane.addEventListener('invalid', this.#onPaneInvalid);
417
+ this.#timePaneBound = true;
418
+ }
419
+ } else if (this.#timePane || this.#divider) {
420
+ // precision switched back to 'date' at runtime — tear the pane down.
421
+ if (this.#timePaneBound) {
422
+ this.#timePane.removeEventListener('change', this.#onTimePaneChange);
423
+ this.#timePane.removeEventListener('input', this.#onTimePaneInput);
424
+ this.#timePane.removeEventListener('invalid', this.#onPaneInvalid);
425
+ this.#timePaneBound = false;
426
+ }
427
+ this.#timePane?.remove();
428
+ this.#divider?.remove();
429
+ this.#timePane = null;
430
+ this.#divider = null;
431
+ this.#pending = null;
432
+ }
433
+ }
434
+
435
+ // Cascade value/min/max/precision knobs into the substrate(s) on every
436
+ // render so external prop writes (not just this class's own selection
437
+ // flow) stay in sync.
438
+ if (this.#grid) {
439
+ const parsed = this.#hasTimePane ? parseDatetime(this.value) : null;
440
+ const datePart = this.#hasTimePane ? (this.#pending?.date ?? parsed?.date ?? '') : (this.value || '');
441
+ this.#grid.value = datePart;
442
+ const minDate = this.#hasTimePane ? (this.min ? this.min.slice(0, 10) : '') : this.min;
443
+ const maxDate = this.#hasTimePane ? (this.max ? this.max.slice(0, 10) : '') : this.max;
444
+ if (minDate) this.#grid.setAttribute('min', minDate); else this.#grid.removeAttribute('min');
445
+ if (maxDate) this.#grid.setAttribute('max', maxDate); else this.#grid.removeAttribute('max');
446
+ this.#grid.toggleAttribute('disabled', !!this.disabled);
447
+ this.#grid.toggleAttribute('readonly', !!this.readonly);
448
+ }
449
+
450
+ if (this.#hasTimePane && this.#timePane) {
451
+ const parsed = parseDatetime(this.value);
452
+ const timePart = this.#pending?.time ?? parsed?.time ?? '';
453
+ this.#timePane.setAttribute('precision', this.precision);
454
+ this.#timePane.setAttribute('step', String(this.step));
455
+ if (this.hourcycle) this.#timePane.setAttribute('hour-cycle', this.hourcycle); else this.#timePane.removeAttribute('hour-cycle');
456
+ if (this.locale) this.#timePane.setAttribute('locale', this.locale); else this.#timePane.removeAttribute('locale');
457
+ // Time-of-day portion of min/max only applies when the pending/parsed
458
+ // date matches the bound's own date — an advisory affordance; the
459
+ // host-level #commitFromParts constraint check is the real gate.
460
+ const datePart = this.#pending?.date ?? parsed?.date ?? '';
461
+ const minTime = this.min ? this.min.slice(11) : '';
462
+ const maxTime = this.max ? this.max.slice(11) : '';
463
+ if (minTime && datePart === this.min.slice(0, 10)) this.#timePane.setAttribute('min', minTime); else this.#timePane.removeAttribute('min');
464
+ if (maxTime && datePart === this.max.slice(0, 10)) this.#timePane.setAttribute('max', maxTime); else this.#timePane.removeAttribute('max');
465
+ if (this.#timePane.value !== timePart) this.#timePane.value = timePart;
466
+ this.#timePane.toggleAttribute('disabled', !!this.disabled);
467
+ this.#timePane.toggleAttribute('readonly', !!this.readonly);
468
+ }
469
+
470
+ // Lift a consumer-authored [slot="footer"] (Apply/Cancel explicit-commit
471
+ // affordance) into the popover, after the pane(s).
472
+ const authorFooter = this.querySelector(':scope > [slot="footer"]');
473
+ if (authorFooter && this.#popover && authorFooter.parentElement !== this.#popover) {
474
+ this.#popover.appendChild(authorFooter);
475
+ }
476
+
477
+ // A disabled/readonly host can't legitimately be open — belt-and-braces
478
+ // circuit breaker (mirrors the former datetime-picker.class.js render()).
479
+ if (this.open && (this.disabled || this.readonly)) {
480
+ this.open = false;
140
481
  }
141
482
 
142
483
  // Toggle popover
484
+ const justOpened = this.open && !this.#wasOpen;
485
+ const justClosed = !this.open && this.#wasOpen;
143
486
  if (this.open) {
144
- this.#renderCalendar();
145
487
  this.#popover?.showPopover?.();
146
- // ADR-0034 Rule 2: calendar panel (~330px) >> trigger button (~200px) → center.
147
- // Consumer can override via placement="bottom-start|top|…" attribute.
488
+ // ADR-0034 Rule 2: calendar panel (~330px, or the two-pane datetime
489
+ // width) >> trigger button (~200px) → center. Consumer can override
490
+ // via placement="bottom-start|top|…" attribute.
148
491
  this.#anchorCleanup = anchorPopover(this.#trigger, this.#popover, {
149
492
  placement: this.getAttribute('placement') || 'bottom', gap: 4,
150
493
  });
@@ -153,6 +496,36 @@ export class UICalendarPicker extends UIFormElement {
153
496
  if (!this.isConnected || !this.open) return;
154
497
  document.addEventListener('pointerdown', this.#onOutside, { once: true });
155
498
  });
499
+ // Move DOM focus into the grid so ITS OWN keydown listener (bound to
500
+ // itself, not this host) receives arrow/Enter/PageUp/PageDown/Home/End
501
+ // — same technique date-range-picker uses for its panes (gh#1371: the
502
+ // old inlined grid worked because #onKey lived on this host and
503
+ // intercepted keys regardless of where focus was; the composed
504
+ // substrate requires focus to actually be inside it). Gated to the
505
+ // closed→open transition only (`justOpened`) — running this on EVERY
506
+ // open-state render would re-steal focus back to the first day after
507
+ // every selection, since #onGridChange's `this.value = iso` triggers
508
+ // another render while still open (review finding, PR #1437).
509
+ if (justOpened) {
510
+ queueMicrotask(() => {
511
+ if (!this.open || !this.#grid) return;
512
+ const selected = this.#grid.querySelector('[data-cal-day][data-selected]:not([data-outside])');
513
+ const target = selected || this.#grid.querySelector('[data-cal-day]:not([disabled]):not([data-outside])');
514
+ const day = target && Number(target.dataset.date?.split('-')[2]);
515
+ // Route through the grid's own focusDay() rather than a raw
516
+ // `target.focus()` — focusDay() sets calendar-grid-ui's internal
517
+ // `#focusedDay` (the roving-tabindex + Enter-to-select state)
518
+ // BEFORE moving DOM focus, so Enter and subsequent arrow-key nav
519
+ // have a correct baseline immediately. A bare `.focus()` moves
520
+ // DOM focus without touching that internal state, leaving Enter
521
+ // with no selection target and arrow nav starting from day 1
522
+ // (review finding, PR #1437).
523
+ if (day) this.#grid.focusDay(day);
524
+ });
525
+ }
526
+ if (justOpened) {
527
+ this.dispatchEvent(new CustomEvent('open', { bubbles: true, detail: { trigger: this.#openTrigger } }));
528
+ }
156
529
  } else {
157
530
  this.#anchorCleanup?.();
158
531
  this.#anchorCleanup = null;
@@ -162,11 +535,23 @@ export class UICalendarPicker extends UIFormElement {
162
535
  }
163
536
  this.#popover?.hidePopover?.();
164
537
  document.removeEventListener('pointerdown', this.#onOutside);
538
+ if (justClosed) {
539
+ this.dispatchEvent(new CustomEvent('close', { bubbles: true, detail: { reason: this.#closeReason } }));
540
+ }
165
541
  }
542
+ this.#wasOpen = this.open;
166
543
  }
167
544
 
168
545
  #displayText() {
169
546
  if (!this.value) return this.placeholder;
547
+ if (this.#hasTimePane) {
548
+ const parsed = parseDatetime(this.value);
549
+ if (!parsed) return this.value;
550
+ const dPart = formatDatePart(parsed.date, this.format);
551
+ const tPart = formatTimePart(parsed.time, this.precision, this.hourcycle);
552
+ if (!dPart || !tPart) return this.placeholder;
553
+ return `${dPart} ${tPart}`;
554
+ }
170
555
  const d = parseISO(this.value);
171
556
  if (!d) return this.value;
172
557
  switch (this.format) {
@@ -180,125 +565,55 @@ export class UICalendarPicker extends UIFormElement {
180
565
  }
181
566
  }
182
567
 
183
- #renderCalendar() {
184
- if (!this.#popover) return;
185
-
186
- const year = this.#viewYear;
187
- const month = this.#viewMonth;
188
- const today = new Date();
189
- const selected = parseISO(this.value);
190
- const minDate = parseISO(this.min);
191
- const maxDate = parseISO(this.max);
192
-
193
- // Header
194
- // <button-ui variant="ghost" icon="…"> (gh issue 276 wave 4) — no
195
- // tabindex here, matching the native version: these nav buttons stay in
196
- // the default tab order (unlike calendar-grid-ui's, which are pulled out
197
- // via tabindex="-1" for its inline roving-tabindex day grid).
198
- let h = `<div data-cal-header>
199
- <button-ui variant="ghost" size="sm" icon="caret-left" data-cal-prev aria-label="Previous month"></button-ui>
200
- <span data-cal-title>${MONTHS_LONG[month]} ${year}</span>
201
- <button-ui variant="ghost" size="sm" icon="caret-right" data-cal-next aria-label="Next month"></button-ui>
202
- </div>`;
203
-
204
- // Day-of-week header
205
- h += `<div data-cal-weekdays>${DAYS.map(d => `<span>${d}</span>`).join('')}</div>`;
206
-
207
- // Calendar grid
208
- const firstDay = new Date(year, month, 1).getDay();
209
- const daysInMonth = new Date(year, month + 1, 0).getDate();
210
- const daysInPrev = new Date(year, month, 0).getDate();
211
-
212
- h += '<div data-cal-grid>';
213
-
214
- // Previous month trailing days
215
- for (let i = firstDay - 1; i >= 0; i--) {
216
- const day = daysInPrev - i;
217
- h += `<button-ui variant="ghost" data-cal-day data-outside disabled tabindex="-1" text="${day}"></button-ui>`;
218
- }
219
-
220
- // Current month days
221
- for (let d = 1; d <= daysInMonth; d++) {
222
- const date = new Date(year, month, d);
223
- const iso = toISO(year, month, d);
224
- const isToday = sameDay(date, today);
225
- const isSelected = selected && sameDay(date, selected);
226
- const isDisabled = (minDate && date < minDate) || (maxDate && date > maxDate);
227
- const isFocused = this.#focusedDay === d;
228
-
229
- const attrs = [
230
- 'data-cal-day',
231
- `data-date="${iso}"`,
232
- isToday ? 'data-today' : '',
233
- isSelected ? 'data-selected' : '',
234
- isDisabled ? 'disabled' : '',
235
- isFocused ? 'data-focused' : '',
236
- `tabindex="${isFocused ? '0' : '-1'}"`,
237
- ].filter(Boolean).join(' ');
238
-
239
- // <button-ui variant="ghost" text="…"> (gh issue 276 wave 4) — the day
240
- // number moves from element text content to the `text` attribute.
241
- h += `<button-ui variant="ghost" ${attrs} text="${d}"></button-ui>`;
242
- }
243
-
244
- // Next month leading days
245
- const totalCells = firstDay + daysInMonth;
246
- const remaining = (7 - (totalCells % 7)) % 7;
247
- for (let d = 1; d <= remaining; d++) {
248
- h += `<button-ui variant="ghost" data-cal-day data-outside disabled tabindex="-1" text="${d}"></button-ui>`;
249
- }
250
-
251
- h += '</div>';
252
-
253
- this.#popover.innerHTML = h;
254
- // Events are delegated on #popover (see #onPopoverClick) — no per-element wiring.
255
- }
256
-
257
- #navigate(delta) {
258
- this.#viewMonth += delta;
259
- if (this.#viewMonth > 11) { this.#viewMonth = 0; this.#viewYear++; }
260
- if (this.#viewMonth < 0) { this.#viewMonth = 11; this.#viewYear--; }
261
- this.#focusedDay = null;
262
- this.#renderCalendar();
263
- }
264
-
265
- #selectDate(iso) {
266
- this.value = iso;
267
- this.syncValue(iso);
268
- this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
269
- // Re-render grid to reflect new selection; keep popover open until user clicks outside.
270
- if (this.open) this.#renderCalendar();
271
- }
272
-
273
568
  #onClick = (e) => {
274
- if (this.disabled) return;
275
- if (this.#popover && e.composedPath().includes(this.#popover)) return;
276
- this.open = !this.open;
277
- if (this.open) {
278
- const sel = parseISO(this.value);
279
- if (sel) {
280
- this.#viewYear = sel.getFullYear();
281
- this.#viewMonth = sel.getMonth();
282
- this.#focusedDay = sel.getDate();
283
- } else {
284
- const today = new Date();
285
- this.#viewYear = today.getFullYear();
286
- this.#viewMonth = today.getMonth();
287
- this.#focusedDay = today.getDate();
569
+ if (this.disabled || this.readonly) return;
570
+ if (this.#popover && e.composedPath().includes(this.#popover)) {
571
+ // Footer button handling — Apply / Cancel semantics for explicit-
572
+ // commit flows when the consumer wires data-action attrs onto
573
+ // authored slot="footer" buttons.
574
+ const actionBtn = e.target.closest?.('[data-action]');
575
+ if (actionBtn && this.contains(actionBtn)) {
576
+ const action = actionBtn.dataset.action;
577
+ if (action === 'apply') {
578
+ this.#commitFromParts({
579
+ date: this.#pending?.date ?? this.#grid?.value ?? '',
580
+ time: this.#pending?.time ?? this.#timePane?.value ?? '',
581
+ });
582
+ this.#closeReason = 'apply';
583
+ this.open = false;
584
+ } else if (action === 'cancel') {
585
+ this.#pending = null;
586
+ this.#closeReason = 'cancel';
587
+ this.open = false;
588
+ }
288
589
  }
590
+ return;
591
+ }
592
+ if (this.open) {
593
+ this.#closeReason = 'outside';
594
+ this.open = false;
595
+ } else {
596
+ this.#openTrigger = 'click';
597
+ this.open = true;
289
598
  }
290
599
  };
291
600
 
292
601
  #onOutside = (e) => {
293
602
  if (!this.contains(e.target) && !(this.#popover && e.composedPath().includes(this.#popover))) {
603
+ this.#closeReason = 'outside';
294
604
  this.open = false;
295
605
  }
296
606
  };
297
607
 
608
+ // Only the OPEN/CLOSE affordance lives here now — day navigation and
609
+ // selection are the composed calendar-grid-ui's own keydown contract
610
+ // once focus is inside it (see the queueMicrotask in render()).
298
611
  #onKey = (e) => {
612
+ if (this.disabled || this.readonly) return;
299
613
  if (!this.open) {
300
614
  if (e.key === 'Enter' || e.key === ' ') {
301
615
  e.preventDefault();
616
+ this.#openTrigger = 'keyboard';
302
617
  this.open = true;
303
618
  }
304
619
  return;
@@ -306,44 +621,8 @@ export class UICalendarPicker extends UIFormElement {
306
621
 
307
622
  if (e.key === 'Escape') {
308
623
  e.preventDefault();
624
+ this.#closeReason = 'escape';
309
625
  this.open = false;
310
- return;
311
- }
312
-
313
- if (e.key === 'Enter') {
314
- e.preventDefault();
315
- if (this.#focusedDay) {
316
- const iso = toISO(this.#viewYear, this.#viewMonth, this.#focusedDay);
317
- const date = new Date(this.#viewYear, this.#viewMonth, this.#focusedDay);
318
- const minDate = parseISO(this.min);
319
- const maxDate = parseISO(this.max);
320
- if ((!minDate || date >= minDate) && (!maxDate || date <= maxDate)) {
321
- this.#selectDate(iso);
322
- }
323
- }
324
- return;
325
- }
326
-
327
- const arrows = { ArrowUp: -7, ArrowDown: 7, ArrowLeft: -1, ArrowRight: 1 };
328
- if (arrows[e.key] !== undefined) {
329
- e.preventDefault();
330
- const daysInMonth = new Date(this.#viewYear, this.#viewMonth + 1, 0).getDate();
331
- let day = (this.#focusedDay || 1) + arrows[e.key];
332
-
333
- if (day < 1) {
334
- this.#navigate(-1);
335
- const prevDays = new Date(this.#viewYear, this.#viewMonth + 1, 0).getDate();
336
- this.#focusedDay = prevDays + day;
337
- } else if (day > daysInMonth) {
338
- this.#navigate(1);
339
- this.#focusedDay = day - daysInMonth;
340
- } else {
341
- this.#focusedDay = day;
342
- }
343
-
344
- this.#renderCalendar();
345
- const focused = this.#popover?.querySelector('[data-focused]');
346
- focused?.focus();
347
626
  }
348
627
  };
349
628
 
@@ -356,14 +635,28 @@ export class UICalendarPicker extends UIFormElement {
356
635
  this.removeEventListener('click', this.#onClick);
357
636
  this.removeEventListener('keydown', this.#onKey);
358
637
  document.removeEventListener('pointerdown', this.#onOutside);
359
- if (this.#popover && this.#popoverClickBound) {
360
- this.#popover.removeEventListener('click', this.#onPopoverClick);
638
+ if (this.#grid && this.#gridBound) {
639
+ this.#grid.removeEventListener('change', this.#onGridChange);
640
+ }
641
+ if (this.#timePane && this.#timePaneBound) {
642
+ this.#timePane.removeEventListener('change', this.#onTimePaneChange);
643
+ this.#timePane.removeEventListener('input', this.#onTimePaneInput);
644
+ this.#timePane.removeEventListener('invalid', this.#onPaneInvalid);
361
645
  }
362
- this.#popoverClickBound = false;
646
+ this.#gridBound = false;
647
+ this.#timePaneBound = false;
363
648
  this.#anchorCleanup?.();
364
649
  this.#anchorCleanup = null;
365
650
  this.#popover?.hidePopover?.();
366
651
  this.#popover = null;
367
652
  this.#trigger = null;
653
+ this.#grid = null;
654
+ this.#divider = null;
655
+ this.#timePane = null;
656
+ this.#pending = null;
657
+ // Re-arm the closed→open transition gate — a reconnect while `open`
658
+ // was never cleared (e.g. moved in the DOM mid-open) must still be
659
+ // treated as a fresh open on the next render, not silently skipped.
660
+ this.#wasOpen = false;
368
661
  }
369
662
  }