@agent-ui-kit/components 0.0.3

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 (271) hide show
  1. package/dist/base-styles.css +8 -0
  2. package/dist/component-styles.css +147 -0
  3. package/dist/controls/_base/index.d.ts +3 -0
  4. package/dist/controls/_base/index.js +9 -0
  5. package/dist/controls/_base/indicator-element.d.ts +132 -0
  6. package/dist/controls/_base/indicator-element.js +158 -0
  7. package/dist/controls/_base/listbox-element.d.ts +70 -0
  8. package/dist/controls/_base/listbox-element.js +74 -0
  9. package/dist/controls/_base/range-element.d.ts +120 -0
  10. package/dist/controls/_base/range-element.js +158 -0
  11. package/dist/controls/_surface/container-box.css +301 -0
  12. package/dist/controls/_surface/container.css +156 -0
  13. package/dist/controls/_token-surface/token-surface.d.ts +35 -0
  14. package/dist/controls/_token-surface/token-surface.js +97 -0
  15. package/dist/controls/attachment/attachment-meta.d.ts +8 -0
  16. package/dist/controls/attachment/attachment-meta.js +84 -0
  17. package/dist/controls/attachment/attachment.css +91 -0
  18. package/dist/controls/attachment/attachment.d.ts +40 -0
  19. package/dist/controls/attachment/attachment.js +70 -0
  20. package/dist/controls/avatar/avatar-initials.d.ts +4 -0
  21. package/dist/controls/avatar/avatar-initials.js +30 -0
  22. package/dist/controls/avatar/avatar.css +87 -0
  23. package/dist/controls/avatar/avatar.d.ts +40 -0
  24. package/dist/controls/avatar/avatar.js +77 -0
  25. package/dist/controls/badge/badge.css +175 -0
  26. package/dist/controls/badge/badge.d.ts +36 -0
  27. package/dist/controls/badge/badge.js +71 -0
  28. package/dist/controls/bar-chart/bar-chart.css +110 -0
  29. package/dist/controls/bar-chart/bar-chart.d.ts +26 -0
  30. package/dist/controls/bar-chart/bar-chart.js +80 -0
  31. package/dist/controls/bar-chart/bar-math.d.ts +31 -0
  32. package/dist/controls/bar-chart/bar-math.js +106 -0
  33. package/dist/controls/button/button.css +263 -0
  34. package/dist/controls/button/button.d.ts +61 -0
  35. package/dist/controls/button/button.js +115 -0
  36. package/dist/controls/calendar/calendar.css +495 -0
  37. package/dist/controls/calendar/calendar.d.ts +161 -0
  38. package/dist/controls/calendar/calendar.js +1004 -0
  39. package/dist/controls/card/card-content.d.ts +24 -0
  40. package/dist/controls/card/card-content.js +133 -0
  41. package/dist/controls/card/card-footer.d.ts +3 -0
  42. package/dist/controls/card/card-footer.js +16 -0
  43. package/dist/controls/card/card-header.d.ts +3 -0
  44. package/dist/controls/card/card-header.js +18 -0
  45. package/dist/controls/card/card.css +445 -0
  46. package/dist/controls/card/card.d.ts +51 -0
  47. package/dist/controls/card/card.js +61 -0
  48. package/dist/controls/checkbox/checkbox.css +204 -0
  49. package/dist/controls/checkbox/checkbox.d.ts +114 -0
  50. package/dist/controls/checkbox/checkbox.js +130 -0
  51. package/dist/controls/code/code.css +72 -0
  52. package/dist/controls/code/code.d.ts +23 -0
  53. package/dist/controls/code/code.js +40 -0
  54. package/dist/controls/color-picker/color-picker.css +209 -0
  55. package/dist/controls/color-picker/color-picker.d.ts +84 -0
  56. package/dist/controls/color-picker/color-picker.js +545 -0
  57. package/dist/controls/color-picker/color.d.ts +40 -0
  58. package/dist/controls/color-picker/color.js +159 -0
  59. package/dist/controls/column/column.css +151 -0
  60. package/dist/controls/column/column.d.ts +107 -0
  61. package/dist/controls/column/column.js +57 -0
  62. package/dist/controls/combo-box/combo-box.css +397 -0
  63. package/dist/controls/combo-box/combo-box.d.ts +133 -0
  64. package/dist/controls/combo-box/combo-box.js +680 -0
  65. package/dist/controls/command-modal/command-modal.css +226 -0
  66. package/dist/controls/command-modal/command-modal.d.ts +42 -0
  67. package/dist/controls/command-modal/command-modal.js +315 -0
  68. package/dist/controls/disclosure/disclosure.css +162 -0
  69. package/dist/controls/disclosure/disclosure.d.ts +37 -0
  70. package/dist/controls/disclosure/disclosure.js +207 -0
  71. package/dist/controls/field/field.css +140 -0
  72. package/dist/controls/field/field.d.ts +27 -0
  73. package/dist/controls/field/field.js +228 -0
  74. package/dist/controls/form-provider/form-provider.css +40 -0
  75. package/dist/controls/form-provider/form-provider.d.ts +42 -0
  76. package/dist/controls/form-provider/form-provider.js +105 -0
  77. package/dist/controls/grid/grid.css +72 -0
  78. package/dist/controls/grid/grid.d.ts +60 -0
  79. package/dist/controls/grid/grid.js +56 -0
  80. package/dist/controls/icon/icon.css +37 -0
  81. package/dist/controls/icon/icon.d.ts +25 -0
  82. package/dist/controls/icon/icon.js +55 -0
  83. package/dist/controls/index.d.ts +57 -0
  84. package/dist/controls/index.js +136 -0
  85. package/dist/controls/ladder/ladder.css +89 -0
  86. package/dist/controls/ladder/ladder.d.ts +27 -0
  87. package/dist/controls/ladder/ladder.js +78 -0
  88. package/dist/controls/list/list.css +118 -0
  89. package/dist/controls/list/list.d.ts +84 -0
  90. package/dist/controls/list/list.js +48 -0
  91. package/dist/controls/menu/menu.css +195 -0
  92. package/dist/controls/menu/menu.d.ts +44 -0
  93. package/dist/controls/menu/menu.js +248 -0
  94. package/dist/controls/modal/modal.css +99 -0
  95. package/dist/controls/modal/modal.d.ts +61 -0
  96. package/dist/controls/modal/modal.js +155 -0
  97. package/dist/controls/popover/popover.css +83 -0
  98. package/dist/controls/popover/popover.d.ts +44 -0
  99. package/dist/controls/popover/popover.js +164 -0
  100. package/dist/controls/progress/progress.css +106 -0
  101. package/dist/controls/progress/progress.d.ts +28 -0
  102. package/dist/controls/progress/progress.js +80 -0
  103. package/dist/controls/radio/radio-group.css +84 -0
  104. package/dist/controls/radio/radio-group.d.ts +152 -0
  105. package/dist/controls/radio/radio-group.js +350 -0
  106. package/dist/controls/radio/radio.css +219 -0
  107. package/dist/controls/radio/radio.d.ts +19 -0
  108. package/dist/controls/radio/radio.js +75 -0
  109. package/dist/controls/ramp/ramp.css +75 -0
  110. package/dist/controls/ramp/ramp.d.ts +29 -0
  111. package/dist/controls/ramp/ramp.js +72 -0
  112. package/dist/controls/row/row.css +152 -0
  113. package/dist/controls/row/row.d.ts +95 -0
  114. package/dist/controls/row/row.js +45 -0
  115. package/dist/controls/segment/segment.css +66 -0
  116. package/dist/controls/segment/segment.d.ts +3 -0
  117. package/dist/controls/segment/segment.js +26 -0
  118. package/dist/controls/segmented-control/segmented-control.css +189 -0
  119. package/dist/controls/segmented-control/segmented-control.d.ts +20 -0
  120. package/dist/controls/segmented-control/segmented-control.js +64 -0
  121. package/dist/controls/select/select.css +467 -0
  122. package/dist/controls/select/select.d.ts +147 -0
  123. package/dist/controls/select/select.js +538 -0
  124. package/dist/controls/slider/slider.css +231 -0
  125. package/dist/controls/slider/slider.d.ts +111 -0
  126. package/dist/controls/slider/slider.js +60 -0
  127. package/dist/controls/slider-multi/slider-multi.css +197 -0
  128. package/dist/controls/slider-multi/slider-multi.d.ts +149 -0
  129. package/dist/controls/slider-multi/slider-multi.js +380 -0
  130. package/dist/controls/sparkline/sparkline-math.d.ts +23 -0
  131. package/dist/controls/sparkline/sparkline-math.js +79 -0
  132. package/dist/controls/sparkline/sparkline.css +61 -0
  133. package/dist/controls/sparkline/sparkline.d.ts +27 -0
  134. package/dist/controls/sparkline/sparkline.js +74 -0
  135. package/dist/controls/split/constrain.d.ts +44 -0
  136. package/dist/controls/split/constrain.js +126 -0
  137. package/dist/controls/split/split-pane.css +63 -0
  138. package/dist/controls/split/split-pane.d.ts +49 -0
  139. package/dist/controls/split/split-pane.js +42 -0
  140. package/dist/controls/split/split.css +127 -0
  141. package/dist/controls/split/split.d.ts +42 -0
  142. package/dist/controls/split/split.js +364 -0
  143. package/dist/controls/stat/stat-model.d.ts +22 -0
  144. package/dist/controls/stat/stat-model.js +69 -0
  145. package/dist/controls/stat/stat.css +101 -0
  146. package/dist/controls/stat/stat.d.ts +30 -0
  147. package/dist/controls/stat/stat.js +73 -0
  148. package/dist/controls/status-stream/status-stream.css +151 -0
  149. package/dist/controls/status-stream/status-stream.d.ts +108 -0
  150. package/dist/controls/status-stream/status-stream.js +417 -0
  151. package/dist/controls/swatch/swatch.css +66 -0
  152. package/dist/controls/swatch/swatch.d.ts +28 -0
  153. package/dist/controls/swatch/swatch.js +74 -0
  154. package/dist/controls/swiper/swiper-item.css +17 -0
  155. package/dist/controls/swiper/swiper-item.d.ts +31 -0
  156. package/dist/controls/swiper/swiper-item.js +47 -0
  157. package/dist/controls/swiper/swiper-label.css +10 -0
  158. package/dist/controls/swiper/swiper-label.d.ts +4 -0
  159. package/dist/controls/swiper/swiper-label.js +20 -0
  160. package/dist/controls/swiper/swiper-paddles.css +26 -0
  161. package/dist/controls/swiper/swiper-paddles.d.ts +10 -0
  162. package/dist/controls/swiper/swiper-paddles.js +62 -0
  163. package/dist/controls/swiper/swiper-pagination.css +60 -0
  164. package/dist/controls/swiper/swiper-pagination.d.ts +31 -0
  165. package/dist/controls/swiper/swiper-pagination.js +78 -0
  166. package/dist/controls/swiper/swiper.css +153 -0
  167. package/dist/controls/swiper/swiper.d.ts +158 -0
  168. package/dist/controls/swiper/swiper.js +659 -0
  169. package/dist/controls/switch/switch.css +194 -0
  170. package/dist/controls/switch/switch.d.ts +5 -0
  171. package/dist/controls/switch/switch.js +18 -0
  172. package/dist/controls/table/table-model.d.ts +43 -0
  173. package/dist/controls/table/table-model.js +124 -0
  174. package/dist/controls/table/table.css +90 -0
  175. package/dist/controls/table/table.d.ts +29 -0
  176. package/dist/controls/table/table.js +178 -0
  177. package/dist/controls/tabs/tab-panel.d.ts +10 -0
  178. package/dist/controls/tabs/tab-panel.js +89 -0
  179. package/dist/controls/tabs/tab.d.ts +26 -0
  180. package/dist/controls/tabs/tab.js +67 -0
  181. package/dist/controls/tabs/tabs.css +181 -0
  182. package/dist/controls/tabs/tabs.d.ts +61 -0
  183. package/dist/controls/tabs/tabs.js +176 -0
  184. package/dist/controls/text/href.d.ts +26 -0
  185. package/dist/controls/text/href.js +48 -0
  186. package/dist/controls/text/text.css +328 -0
  187. package/dist/controls/text/text.d.ts +85 -0
  188. package/dist/controls/text/text.js +273 -0
  189. package/dist/controls/text-field/text-field.css +498 -0
  190. package/dist/controls/text-field/text-field.d.ts +226 -0
  191. package/dist/controls/text-field/text-field.js +905 -0
  192. package/dist/controls/textarea/textarea.css +251 -0
  193. package/dist/controls/textarea/textarea.d.ts +136 -0
  194. package/dist/controls/textarea/textarea.js +312 -0
  195. package/dist/controls/theme-provider/theme-provider.css +48 -0
  196. package/dist/controls/theme-provider/theme-provider.d.ts +59 -0
  197. package/dist/controls/theme-provider/theme-provider.js +40 -0
  198. package/dist/controls/timeline/timeline.css +33 -0
  199. package/dist/controls/timeline/timeline.d.ts +39 -0
  200. package/dist/controls/timeline/timeline.js +52 -0
  201. package/dist/controls/timeline-item/timeline-item.css +495 -0
  202. package/dist/controls/timeline-item/timeline-item.d.ts +79 -0
  203. package/dist/controls/timeline-item/timeline-item.js +360 -0
  204. package/dist/controls/toast/toast-region.css +56 -0
  205. package/dist/controls/toast/toast-region.d.ts +20 -0
  206. package/dist/controls/toast/toast-region.js +113 -0
  207. package/dist/controls/toast/toast.css +96 -0
  208. package/dist/controls/toast/toast.d.ts +28 -0
  209. package/dist/controls/toast/toast.js +191 -0
  210. package/dist/controls/toolbar/toolbar.css +125 -0
  211. package/dist/controls/toolbar/toolbar.d.ts +108 -0
  212. package/dist/controls/toolbar/toolbar.js +68 -0
  213. package/dist/controls/tooltip/tooltip.css +80 -0
  214. package/dist/controls/tooltip/tooltip.d.ts +57 -0
  215. package/dist/controls/tooltip/tooltip.js +205 -0
  216. package/dist/descriptor/component-descriptor.d.ts +112 -0
  217. package/dist/descriptor/component-descriptor.js +492 -0
  218. package/dist/descriptor/index.d.ts +1 -0
  219. package/dist/descriptor/index.js +5 -0
  220. package/dist/dom/container.d.ts +45 -0
  221. package/dist/dom/container.js +60 -0
  222. package/dist/dom/element.d.ts +98 -0
  223. package/dist/dom/element.js +221 -0
  224. package/dist/dom/form.d.ts +240 -0
  225. package/dist/dom/form.js +399 -0
  226. package/dist/dom/index.d.ts +10 -0
  227. package/dist/dom/index.js +31 -0
  228. package/dist/dom/props.d.ts +56 -0
  229. package/dist/dom/props.js +231 -0
  230. package/dist/dom/repeat.d.ts +10 -0
  231. package/dist/dom/repeat.js +202 -0
  232. package/dist/dom/template.d.ts +161 -0
  233. package/dist/dom/template.js +634 -0
  234. package/dist/dom/watch.d.ts +11 -0
  235. package/dist/dom/watch.js +82 -0
  236. package/dist/foundation-styles.css +9 -0
  237. package/dist/index.d.ts +2 -0
  238. package/dist/index.js +3 -0
  239. package/dist/reactive/graph.d.ts +65 -0
  240. package/dist/reactive/graph.js +310 -0
  241. package/dist/reactive/index.d.ts +3 -0
  242. package/dist/reactive/index.js +4 -0
  243. package/dist/reactive/scheduler.d.ts +13 -0
  244. package/dist/reactive/scheduler.js +49 -0
  245. package/dist/traits/area-drag.d.ts +12 -0
  246. package/dist/traits/area-drag.js +70 -0
  247. package/dist/traits/form-registry.d.ts +36 -0
  248. package/dist/traits/form-registry.js +108 -0
  249. package/dist/traits/index.d.ts +24 -0
  250. package/dist/traits/index.js +19 -0
  251. package/dist/traits/overlay.d.ts +50 -0
  252. package/dist/traits/overlay.js +277 -0
  253. package/dist/traits/pane-resize.d.ts +26 -0
  254. package/dist/traits/pane-resize.js +103 -0
  255. package/dist/traits/press-activation.d.ts +11 -0
  256. package/dist/traits/press-activation.js +55 -0
  257. package/dist/traits/roving-focus.d.ts +52 -0
  258. package/dist/traits/roving-focus.js +214 -0
  259. package/dist/traits/scroll-fade.d.ts +28 -0
  260. package/dist/traits/scroll-fade.js +120 -0
  261. package/dist/traits/selection-commit.d.ts +30 -0
  262. package/dist/traits/selection-commit.js +146 -0
  263. package/dist/traits/tabbable.d.ts +12 -0
  264. package/dist/traits/tabbable.js +59 -0
  265. package/dist/traits/track-user-invalid.d.ts +36 -0
  266. package/dist/traits/track-user-invalid.js +52 -0
  267. package/dist/traits/value-codec.d.ts +120 -0
  268. package/dist/traits/value-codec.js +497 -0
  269. package/dist/traits/value-drag.d.ts +18 -0
  270. package/dist/traits/value-drag.js +84 -0
  271. package/package.json +263 -0
@@ -0,0 +1,1004 @@
1
+ // calendar.ts — UICalendarElement, the Wave-5B standalone month-grid date picker control.
2
+ // (control-suite-wave5-input-codecs-pickers.decomp.md 5B-1 · ADR-0048; range mode: ADR-0093 /
3
+ // calendar-range.decomp-v2.json)
4
+ //
5
+ // A FACE form control (extends UIFormElement) that contributes a selected ISO date (YYYY-MM-DD)
6
+ // to a form AND will serve as the popup body for type=date on ui-text-field (lazily imported
7
+ // there in slice 5B-3 — this slice builds the standalone control only).
8
+ //
9
+ // MODE FLAG (ADR-0093) — `mode: 'single'|'range'`, default 'single'. ONE calendar, mode-flagged
10
+ // (Kim's F1 ruling; no sibling component, no `_base/` extraction — the machinery below stays
11
+ // #-private). The ONE-LIVE-VALUE-SURFACE rule (ADR-0093 clause 1) is the load-bearing safety net:
12
+ // in mode="single" `value` is live and `valueStart`/`valueEnd` are INERT (held + reflected,
13
+ // contribute NOTHING to render/formValue/formValidity/events); in mode="range" the pair is live
14
+ // and `value` is inert. The control NEVER writes an off-mode prop — every write site below is
15
+ // branched by `this.mode` so a mode switch never migrates data. Range interaction (clause 3):
16
+ // single grid, two picks, SWAP-COMPLETE — the second commit gesture always completes as
17
+ // `[min,max]` of {pick, pending start} (lexicographic ISO compare, zero Date construction), so a
18
+ // pick earlier than the pending start reorders rather than restarting (Kim's F2 ruling). The pick
19
+ // STATE ITSELF is derived from the props (idle = both '' · selecting-end = start set, end '' ·
20
+ // complete = both set, any order) — no extra private state field, so declarative markup
21
+ // (`<ui-calendar mode="range" value-start="…" value-end="…">`) is upgrade-order-safe by
22
+ // construction. Escape is NOT intercepted (ADR-0045 — dismissal stays the overlay's).
23
+ //
24
+ // ANATOMY (parts created ONCE — idempotent across disconnect/reconnect):
25
+ // <ui-calendar>
26
+ // <div data-part="panel" data-box>
27
+ // <header data-part="nav">
28
+ // <button data-part="prev" type="button" aria-label="Previous month"><svg>…caret-left…</svg></button>
29
+ // <span data-part="title" id="uid-N" aria-live="polite">July 2026</span>
30
+ // <button data-part="next" type="button" aria-label="Next month"><svg>…caret-right…</svg></button>
31
+ // </header>
32
+ // <div data-part="grid" role="grid" aria-labelledby="uid-N">
33
+ // <div role="row"> <!-- weekday column-header row -->
34
+ // <span role="columnheader" aria-label="Sunday">Su</span>…7 cols…
35
+ // </div>
36
+ // <div role="row"> <!-- one week (6 such rows) -->
37
+ // <button role="gridcell" type="button" tabindex="-1|0"
38
+ // data-date="YYYY-MM-DD" aria-label="Month D, YYYY"
39
+ // aria-selected="true|false" [aria-disabled="true"]
40
+ // [data-today] [data-outside]>D</button>
41
+ // </div>
42
+ // </div>
43
+ // </div>
44
+ // </ui-calendar>
45
+ //
46
+ // BESPOKE 2D GRID KEYBOARD (ADR-0048 decision 2 — NOT a roving-focus fork; model is distinct):
47
+ // ←/→ ±1 day · ↑/↓ ±7 days · Home/End week-start/-end (Sun/Sat) ·
48
+ // PageUp/Down ±1 month (REGENERATES the grid) · Shift+PageUp/Down ±1 year ·
49
+ // Enter/Space commit (disabled / out-of-range = no-op, enters preventDefault regardless).
50
+ // Roving tabindex=0: #focusIso cursor → selected day (if in month) → today (if in month)
51
+ // → first day of month. Adjacent-month cells shown as [data-outside]; navigating/clicking
52
+ // them rebuilds the grid for that month (standard calendar UX).
53
+ //
54
+ // DATE MATH: all ISO parsing uses explicit y/m/d extraction to avoid new Date('YYYY-MM-DD')
55
+ // UTC-midnight parsing traps. Arithmetic uses new Date(y, m-1, d ± delta) LOCAL construction
56
+ // which handles month/year boundaries correctly without any string parsing.
57
+ //
58
+ // TOKENS CONSUMED (with sensible CSS fallbacks until the tokens-specialist delivers them):
59
+ // --ui-calendar-cell-size · --ui-calendar-gap · --ui-calendar-selected-fill ·
60
+ // --ui-calendar-selected-ink · --ui-calendar-today-ring · --ui-calendar-disabled-ink ·
61
+ // --ui-calendar-outside-ink · --ui-calendar-range-fill · --ui-calendar-range-ink (ADR-0093,
62
+ // realized as the --md-sys-color-primary-surface-highest / -primary-on-surface AA pair —
63
+ // repointed off -primary-surface per the 2026-07-07 erratum, see ADR-0093).
64
+ //
65
+ // FORM SEAMS: mode="single" — formValue() = ISO string (null when none); formValidity() =
66
+ // valueMissing + rangeUnderflow/Overflow; formReset() → initial value attribute;
67
+ // formStateRestore(string). mode="range" (ADR-0093 clause 2) — formValue() = FormData with TWO
68
+ // entries under `name` (start first) when the pair is complete AND not inverted, else null (a
69
+ // range is atomic); formValidity() adds half-open + inverted (programmatic-only — interaction
70
+ // always swap-completes) + per-endpoint min/max; formReset() → the initial value-start/value-end
71
+ // attributes; formStateRestore(FormData) reads the two same-name entries.
72
+ //
73
+ // Layer: controls/ → dom + reactive (inward-only ✓). No overlay, roving-focus, or
74
+ // selectionCommit traits: this control owns its bespoke navigation model (ADR-0048 §2).
75
+ // erasableSyntaxOnly ✓ (no enum/namespace/decorators). verbatimModuleSyntax ✓ (import type).
76
+ import { UIFormElement } from "../../dom/index.js";
77
+ import { prop } from "../../dom/index.js";
78
+ import { trackUserInvalid } from "../../traits/track-user-invalid.js";
79
+ import { setIcon } from '@agent-ui-kit/icons';
80
+ // ── Module-level stable-id counter (one per title/grid pair, never reused) ─────────────────
81
+ let _nextCalendarId = 0;
82
+ // ── Static date-math helpers (pure functions — timezone-safe) ────────────────────────────────
83
+ /**
84
+ * Parse an ISO 'YYYY-MM-DD' string into { y, m, d } integers. Returns null on any malformed
85
+ * input. Does NOT use new Date('YYYY-MM-DD'), which parses as UTC midnight and can shift the
86
+ * calendar day by ±1 in negative-offset timezones.
87
+ */
88
+ function parseDateStr(s) {
89
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
90
+ if (!match)
91
+ return null;
92
+ return { y: Number(match[1]), m: Number(match[2]), d: Number(match[3]) };
93
+ }
94
+ /** Format integer y/m/d as ISO 'YYYY-MM-DD'. */
95
+ function dateStr(y, m, d) {
96
+ return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
97
+ }
98
+ /**
99
+ * Advance a LOCAL date by `delta` days. Uses `new Date(y, m-1, d + delta)` (LOCAL month/day
100
+ * construction — no string parsing) so month/year rollovers are handled by the Date engine.
101
+ */
102
+ function advanceDate(y, m, d, delta) {
103
+ const dt = new Date(y, m - 1, d + delta);
104
+ return { y: dt.getFullYear(), m: dt.getMonth() + 1, d: dt.getDate() };
105
+ }
106
+ /**
107
+ * Add `n` months to a (y, m) pair (1-based month). Handles year rollovers via LOCAL Date.
108
+ * Result is always normalized: no month < 1 or > 12.
109
+ */
110
+ function addMonths(y, m, n) {
111
+ const dt = new Date(y, m - 1 + n, 1);
112
+ return { y: dt.getFullYear(), m: dt.getMonth() + 1 };
113
+ }
114
+ /**
115
+ * The number of days in (y, m) (1-based month). Correctly handles leap February.
116
+ * `new Date(y, m, 0)` — month `m` is 0-based, so this is day 0 of the NEXT month = last day
117
+ * of month m (1-based). Example: m=2 (Feb) → `new Date(y, 2, 0)` = Feb's last day.
118
+ */
119
+ function daysInMonth(y, m) {
120
+ return new Date(y, m, 0).getDate();
121
+ }
122
+ /**
123
+ * True when `iso` is outside the [min, max] range. ISO 'YYYY-MM-DD' strings are
124
+ * lexicographically ordered when zero-padded, so string comparison IS chronological.
125
+ * Empty min/max = unbounded.
126
+ */
127
+ function isOutOfRange(iso, min, max) {
128
+ if (min !== '' && iso < min)
129
+ return true;
130
+ if (max !== '' && iso > max)
131
+ return true;
132
+ return false;
133
+ }
134
+ /**
135
+ * ADR-0093 clause 5 — the range in-range/normalize mechanism, reusing `isOutOfRange`'s proof that
136
+ * zero-padded ISO strings compare chronologically: lexicographic min/max of two NON-empty ISO
137
+ * strings. Used for both the swap-complete commit and the normalized preview — the same function,
138
+ * so the committed band is provably identical to what was previewed (ADR-0093 clause 3).
139
+ */
140
+ function normalizeRange(a, b) {
141
+ return a <= b ? [a, b] : [b, a];
142
+ }
143
+ /** Format an ISO date long-form for AT announcements ("July 15, 2026"); `iso` unchanged if malformed. */
144
+ function formatIsoLong(iso) {
145
+ const p = parseDateStr(iso);
146
+ return p ? `${MONTH_NAMES[p.m - 1]} ${p.d}, ${p.y}` : iso;
147
+ }
148
+ /** One cell's range-selection facts (ADR-0093 clause 4) — a pure function of the pair + the preview
149
+ * candidate, ZERO Date construction (grep-provable). Single mode never calls this.
150
+ *
151
+ * `a` is always the anchor (`valueStart`, whenever it is set); `b` is the completed `valueEnd`
152
+ * when the pair is complete, else the live preview candidate (hover/keyboard-focus) while
153
+ * selecting-end. `a` keeps ITS OWN endpoint mark regardless of chronological order (ADR-0093
154
+ * clause 3: "the pending start keeping its endpoint mark") — the interior wash alone is
155
+ * normalized via `normalizeRange`. */
156
+ function rangeSelectionFor(iso, valueStart, valueEnd, previewIso) {
157
+ const a = valueStart;
158
+ const b = valueEnd !== '' ? valueEnd : previewIso;
159
+ if (a === '')
160
+ return { selected: false, rangeStart: false, rangeEnd: false, inRange: false };
161
+ const rangeStart = iso === a;
162
+ const rangeEnd = b !== '' && iso === b;
163
+ let inBand = rangeStart;
164
+ if (b !== '') {
165
+ const [lo, hi] = normalizeRange(a, b);
166
+ inBand = iso >= lo && iso <= hi;
167
+ }
168
+ return { selected: rangeStart || rangeEnd || inBand, rangeStart, rangeEnd, inRange: inBand && !rangeStart && !rangeEnd };
169
+ }
170
+ // ── Label data (English, static — calendar spec doesn't require Intl localization) ──────────
171
+ const MONTH_NAMES = [
172
+ 'January', 'February', 'March', 'April', 'May', 'June',
173
+ 'July', 'August', 'September', 'October', 'November', 'December',
174
+ ];
175
+ const WEEKDAY_SHORT = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
176
+ const WEEKDAY_FULL = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
177
+ // ── Props ────────────────────────────────────────────────────────────────────────────────────
178
+ const props = {
179
+ // Universal form attributes from the spreadable formProps (name / disabled / required).
180
+ // All three reflect: name for submission keying, disabled/required for attribute-selector styling.
181
+ ...UIFormElement.formProps,
182
+ // `mode` — ADR-0093: 'single' (default) keeps `value` live; 'range' keeps `valueStart`/
183
+ // `valueEnd` live instead (the ONE-LIVE-VALUE-SURFACE rule — clause 1). An enum, not a bare
184
+ // boolean, leaving room for foreseeable future modes (matches the fleet's `size` enum pattern).
185
+ mode: { ...prop.enum(['single', 'range'], 'single'), reflect: true },
186
+ // `value` — the selected date as an ISO 'YYYY-MM-DD' string. '' = nothing selected.
187
+ // Reflected so `<ui-calendar value="2026-07-01">` works declaratively AND the renderer
188
+ // two-way-binds it (value:{prop:'value', event:'change'}). INERT in mode="range" (ADR-0093
189
+ // clause 1) — held + reflected, but contributes nothing to render/formValue/formValidity/events.
190
+ value: { ...prop.string(''), reflect: true },
191
+ // `valueStart` / `valueEnd` — ADR-0093 clause 1: the range-mode value pair, mirroring
192
+ // ui-slider-multi's `valueLo`/`valueHi` shape exactly (kebab attrs via explicit `attribute:`
193
+ // overrides; '' = unset). INERT in mode="single" — held + reflected, contribute nothing.
194
+ valueStart: { ...prop.string(''), reflect: true, attribute: 'value-start' },
195
+ valueEnd: { ...prop.string(''), reflect: true, attribute: 'value-end' },
196
+ // `min`/`max` — ISO date bounds ('' = unbounded). Drive formValidity() range checks and
197
+ // aria-disabled on out-of-range day cells. Reflected for native attribute-IDL parity with
198
+ // text-field min/max (ADR-0047 fleet consistency ruling).
199
+ min: { ...prop.string(''), reflect: true },
200
+ max: { ...prop.string(''), reflect: true },
201
+ // `size` — presentational cell-size tier. Reflected so [size=sm/lg] CSS rules engage on
202
+ // both JS-set and HTML-authored values (fleet standard; same pattern as UIIndicatorElement.props).
203
+ size: { ...prop.enum(['sm', 'md', 'lg'], 'md'), reflect: true },
204
+ };
205
+ export class UICalendarElement extends UIFormElement {
206
+ static props = props;
207
+ // Shell parts created ONCE (idempotent across disconnect/reconnect).
208
+ #panelEl = null;
209
+ #gridEl = null;
210
+ #titleEl = null;
211
+ #prevBtn = null;
212
+ #nextBtn = null;
213
+ #statusEl = null; // ADR-0093 — visually-hidden aria-live status (range mode only)
214
+ // The HTML-authored initial value captured at FIRST connect (before any reactive updates). Since
215
+ // `value` has `reflect: true`, setting `el.value = x` synchronously updates the content attribute —
216
+ // so `getAttribute('value')` at formReset time always returns the CURRENT value, not the original.
217
+ // Capturing here gives us the `<ui-calendar value="…">` default for form-reset parity.
218
+ #initialValue = '';
219
+ // ADR-0093 — the range pair's own HTML-authored initial values, captured the same way (and
220
+ // independently of #initialValue — mode-aware formReset() restores only the currently-live surface).
221
+ #initialValueStart = '';
222
+ #initialValueEnd = '';
223
+ // ADR-0093 clause 3 — the live hover/keyboard-focus PREVIEW candidate while selecting-end
224
+ // (valueStart set, valueEnd ''). NOT a reactive prop (purely ephemeral UI state); rendering reads
225
+ // it directly. '' = no candidate (nothing hovered/focused since the anchor was picked).
226
+ #previewIso = '';
227
+ // The currently displayed month (1-based). Updated only by navigation actions, which call
228
+ // #rebuildGrid() directly. Not reactive signals — the effect handles prop-change updates.
229
+ #displayYear = 0;
230
+ #displayMonth = 0;
231
+ // ISO date of the cell that currently holds tabindex=0 (the roving keyboard cursor).
232
+ // '' = unset; on first connect it is computed from value/today/first (see #computeFocusTarget).
233
+ #focusIso = '';
234
+ // The user-invalid TIMING controller (ADR-0051), created per connection (re-arms on reconnect;
235
+ // released on disconnect) — the text-field/select precedent.
236
+ #userInvalid = null;
237
+ // ── Form seams (UIFormElement hooks) ─────────────────────────────────────────────────────
238
+ formValue() {
239
+ if (this.mode === 'range') {
240
+ // ADR-0093 clause 2: a range is ATOMIC — a half-open or inverted pair contributes nothing.
241
+ const start = this.valueStart;
242
+ const end = this.valueEnd;
243
+ if (start === '' || end === '' || start > end)
244
+ return null;
245
+ const fd = new FormData();
246
+ const name = this.name || '';
247
+ fd.append(name, start); // start FIRST — the ui-slider-multi formValue() precedent
248
+ fd.append(name, end);
249
+ return fd;
250
+ }
251
+ // Null when nothing is selected — no form-data entry contributed. `value` is INERT in
252
+ // mode="range" (never read here — the branch above never falls through to it).
253
+ return this.value !== '' ? this.value : null;
254
+ }
255
+ formValidity() {
256
+ if (this.mode === 'range') {
257
+ const start = this.valueStart;
258
+ const end = this.valueEnd;
259
+ const hasStart = start !== '';
260
+ const hasEnd = end !== '';
261
+ if (!hasStart && !hasEnd) {
262
+ if (this.required) {
263
+ return { valid: false, flags: { valueMissing: true }, message: 'Please select a date range.' };
264
+ }
265
+ return { valid: true };
266
+ }
267
+ if (hasStart !== hasEnd) {
268
+ // Half-open (clause 2) — a range's value is atomic; exactly one endpoint is never a valid
269
+ // submission, `required` or not (there is nothing coherent to submit yet).
270
+ return { valid: false, flags: { valueMissing: true }, message: 'Please select an end date.' };
271
+ }
272
+ // Both endpoints set.
273
+ if (start > end) {
274
+ // Inverted (clause 2) — reachable ONLY by a programmatic set; interaction always
275
+ // swap-completes (clause 3). The platform's ValidityStateFlags has no "inverted" flag;
276
+ // rangeUnderflow (start "underflows" past end) is the closest structural fit — the
277
+ // MESSAGE, not the flag, carries the real diagnosis.
278
+ return { valid: false, flags: { rangeUnderflow: true }, message: 'The start date must be on or before the end date.' };
279
+ }
280
+ if (this.min !== '' && start < this.min) {
281
+ return { valid: false, flags: { rangeUnderflow: true }, message: `Value must be on or after ${this.min}.` };
282
+ }
283
+ if (this.max !== '' && end > this.max) {
284
+ return { valid: false, flags: { rangeOverflow: true }, message: `Value must be on or before ${this.max}.` };
285
+ }
286
+ return { valid: true };
287
+ }
288
+ if (this.required && this.value === '') {
289
+ return { valid: false, flags: { valueMissing: true }, message: 'Please select a date.' };
290
+ }
291
+ if (this.value !== '') {
292
+ if (this.min !== '' && this.value < this.min) {
293
+ return {
294
+ valid: false,
295
+ flags: { rangeUnderflow: true },
296
+ message: `Value must be on or after ${this.min}.`,
297
+ };
298
+ }
299
+ if (this.max !== '' && this.value > this.max) {
300
+ return {
301
+ valid: false,
302
+ flags: { rangeOverflow: true },
303
+ message: `Value must be on or before ${this.max}.`,
304
+ };
305
+ }
306
+ }
307
+ return { valid: true };
308
+ }
309
+ formReset() {
310
+ // ADR-0093 clause 1: reset acts on the CURRENTLY-LIVE surface only — never writes the
311
+ // off-mode prop. Restore to the HTML-authored initial value(s) captured at first connect.
312
+ if (this.mode === 'range') {
313
+ this.valueStart = this.#initialValueStart;
314
+ this.valueEnd = this.#initialValueEnd;
315
+ }
316
+ else {
317
+ this.value = this.#initialValue;
318
+ }
319
+ // ADR-0051 — a reset must not leave a required-empty calendar showing :state(user-invalid)
320
+ // until the user re-interacts (the text-field formReset() precedent).
321
+ this.#userInvalid?.reset();
322
+ }
323
+ formStateRestore(state) {
324
+ if (this.mode === 'range') {
325
+ if (!(state instanceof FormData))
326
+ return;
327
+ const name = this.name || '';
328
+ const vals = state.getAll(name).filter((v) => typeof v === 'string');
329
+ const ISO = /^\d{4}-\d{2}-\d{2}$/;
330
+ if (vals.length !== 2 || !ISO.test(vals[0]) || !ISO.test(vals[1]))
331
+ return;
332
+ const [start, end] = vals;
333
+ const p = parseDateStr(start);
334
+ if (!p)
335
+ return;
336
+ this.valueStart = start;
337
+ this.valueEnd = end;
338
+ this.#displayYear = p.y;
339
+ this.#displayMonth = p.m;
340
+ this.#focusIso = start;
341
+ this.#rebuildGrid();
342
+ return;
343
+ }
344
+ if (typeof state === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(state)) {
345
+ const p = parseDateStr(state);
346
+ if (!p)
347
+ return;
348
+ this.value = state;
349
+ // Navigate the displayed grid to the restored value's month so the selection is visible.
350
+ // (Setting `this.value` alone only updates aria-selected on the existing cells, which may be
351
+ // a different month entirely.) Does NOT steal focus — no #focusCurrentCell() call here.
352
+ this.#displayYear = p.y;
353
+ this.#displayMonth = p.m;
354
+ this.#focusIso = state;
355
+ this.#rebuildGrid();
356
+ }
357
+ }
358
+ // ── Connection lifecycle ──────────────────────────────────────────────────────────────────
359
+ connected() {
360
+ const { grid, prev, next } = this.#ensureShell();
361
+ // Capture the HTML-authored initial value(s) BEFORE any reactive updates (first connect
362
+ // only). `reflect: true` means el.value = x updates the attribute synchronously, so we
363
+ // cannot read getAttribute later and expect the original HTML value. Capture once here
364
+ // instead — both surfaces, independently of `mode` (ADR-0093: attribute-processing order
365
+ // must not matter, so BOTH captures always run regardless of which mode is authored).
366
+ if (this.#initialValue === '' && this.getAttribute('value')) {
367
+ this.#initialValue = this.getAttribute('value');
368
+ }
369
+ if (this.#initialValueStart === '' && this.getAttribute('value-start')) {
370
+ this.#initialValueStart = this.getAttribute('value-start');
371
+ }
372
+ if (this.#initialValueEnd === '' && this.getAttribute('value-end')) {
373
+ this.#initialValueEnd = this.getAttribute('value-end');
374
+ }
375
+ // Seed the displayed month from the currently-LIVE value surface (or today if none set) —
376
+ // mode-aware so `<ui-calendar mode="range" value-start="…">` shows the right month regardless
377
+ // of attribute order (ADR-0093 clause 1).
378
+ const today = this.#today();
379
+ const seedIso = this.mode === 'range'
380
+ ? (this.valueStart !== '' ? this.valueStart : this.valueEnd)
381
+ : this.value;
382
+ const seeded = parseDateStr(seedIso) ?? today;
383
+ this.#displayYear = seeded.y;
384
+ this.#displayMonth = seeded.m;
385
+ // Seed the keyboard cursor: selected date(s) → today (if in month) → first day of month.
386
+ if (this.mode === 'range') {
387
+ if (this.valueStart !== '') {
388
+ this.#focusIso = this.valueStart;
389
+ }
390
+ else if (this.valueEnd !== '') {
391
+ this.#focusIso = this.valueEnd;
392
+ }
393
+ else if (seeded.y === today.y && seeded.m === today.m) {
394
+ this.#focusIso = dateStr(today.y, today.m, today.d);
395
+ }
396
+ else {
397
+ this.#focusIso = dateStr(seeded.y, seeded.m, 1);
398
+ }
399
+ }
400
+ else if (this.value !== '') {
401
+ this.#focusIso = this.value;
402
+ }
403
+ else if (seeded.y === today.y && seeded.m === today.m) {
404
+ this.#focusIso = dateStr(today.y, today.m, today.d);
405
+ }
406
+ else {
407
+ this.#focusIso = dateStr(seeded.y, seeded.m, 1);
408
+ }
409
+ // Build the initial grid.
410
+ this.#rebuildGrid();
411
+ // ── ADR-0051 — the user-invalid TIMING controller ─────────────────────────────────────
412
+ // `blur` is captured at the host (never bubbles; the capture phase reaches this ancestor
413
+ // before whichever gridcell button held focus — the track-user-invalid.ts precedent);
414
+ // `change` is emitted directly on `this` by #commitDate/#commitRangeDate, so it lands here
415
+ // regardless of which commit path fired. Reflects :state(user-invalid) + aria-invalid on the
416
+ // grid part (the role-carrying part — role='grid' rides the part, not internals.role).
417
+ const invalidController = trackUserInvalid(this, { invalid: () => !this.formValidity().valid });
418
+ this.#userInvalid = invalidController;
419
+ this.effect(() => {
420
+ if (invalidController.userInvalid()) {
421
+ this.internals.states?.add('user-invalid');
422
+ grid.setAttribute('aria-invalid', 'true');
423
+ }
424
+ else {
425
+ this.internals.states?.delete('user-invalid');
426
+ grid.removeAttribute('aria-invalid');
427
+ }
428
+ });
429
+ // Reactive effect: when value/valueStart/valueEnd/mode/min/max/disabled change, update
430
+ // existing cells' ARIA + range data attributes without rebuilding the DOM. Reading these
431
+ // signals here registers them as dependencies — the effect re-runs whenever any changes.
432
+ this.effect(() => {
433
+ const v = this.value;
434
+ const min = this.min;
435
+ const max = this.max;
436
+ const dis = this.effectiveDisabled();
437
+ const mode = this.mode;
438
+ const vs = this.valueStart;
439
+ const ve = this.valueEnd;
440
+ this.#updateCellStates(v, min, max, dis, mode, vs, ve, this.#previewIso);
441
+ });
442
+ // Prev/Next month navigation buttons.
443
+ this.listen(prev, 'click', () => {
444
+ const nm = addMonths(this.#displayYear, this.#displayMonth, -1);
445
+ this.#navigateToMonth(nm.y, nm.m);
446
+ });
447
+ this.listen(next, 'click', () => {
448
+ const nm = addMonths(this.#displayYear, this.#displayMonth, 1);
449
+ this.#navigateToMonth(nm.y, nm.m);
450
+ });
451
+ // Bespoke 2D grid keyboard handler (ADR-0048 decision 2; range preview: ADR-0093 clause 3).
452
+ this.listen(grid, 'keydown', (event) => {
453
+ this.#handleGridKey(event);
454
+ });
455
+ // Grid click handler — commit via event delegation, navigate adjacent-month cells.
456
+ this.listen(grid, 'click', (event) => {
457
+ this.#handleGridClick(event);
458
+ });
459
+ // ADR-0093 clause 3 — hover preview while selecting-end. `pointerover` bubbles (delegation,
460
+ // like click); `pointerleave` on the grid itself (does NOT bubble — fires only when the
461
+ // pointer truly leaves the grid's box, not on cell-to-cell movement within it) clears the
462
+ // candidate. Both are no-ops outside mode="range" and outside the selecting-end state.
463
+ this.listen(grid, 'pointerover', (event) => {
464
+ if (this.mode !== 'range')
465
+ return;
466
+ if (this.valueStart === '' || this.valueEnd !== '')
467
+ return; // not selecting-end
468
+ const hit = event.target;
469
+ const cell = hit?.closest('[role="gridcell"]');
470
+ if (!cell || !this.#gridEl?.contains(cell))
471
+ return;
472
+ const iso = cell.dataset['date'];
473
+ if (!iso || iso === this.#previewIso)
474
+ return;
475
+ this.#previewIso = iso;
476
+ this.#refreshCellVisuals();
477
+ });
478
+ this.listen(grid, 'pointerleave', () => {
479
+ if (this.#previewIso === '')
480
+ return;
481
+ this.#previewIso = '';
482
+ this.#refreshCellVisuals();
483
+ });
484
+ // Blur clears too — focus leaving the grid ENTIRELY (relatedTarget outside it) ends the
485
+ // preview; focus moving BETWEEN cells inside the grid (arrow-key nav) keeps previewing (that
486
+ // path already re-set #previewIso itself, above). No-op whenever previewIso is already ''
487
+ // (i.e. always, in mode="single").
488
+ this.listen(grid, 'focusout', (event) => {
489
+ const to = event.relatedTarget;
490
+ if (to && this.#gridEl?.contains(to))
491
+ return;
492
+ if (this.#previewIso === '')
493
+ return;
494
+ this.#previewIso = '';
495
+ this.#refreshCellVisuals();
496
+ });
497
+ }
498
+ disconnected() {
499
+ this.#userInvalid?.release(); // idempotent — the listeners already die with the connection scope
500
+ this.#userInvalid = null;
501
+ }
502
+ /** Feeds `FormConnectDetail.userInvalid` (ADR-0050) — the `trackUserInvalid` tracker IS the one
503
+ * timing source; this override just exposes its gate (the text-field/select precedent). */
504
+ formUserInvalid() {
505
+ return this.#userInvalid?.userInvalid() ?? false;
506
+ }
507
+ // ── ADR-0051 — the field-labelling seam wire (the calendar-merge override) ───────────────────
508
+ /**
509
+ * The part-role override (ADR-0051 cl.2/cl.Consequences "calendar is a follow-up too") — the
510
+ * grid's `role='grid'` rides a light-DOM PART attribute, not `internals.role`, so the base's
511
+ * guarded internals-reflection default (dom/form.ts) never fires for this control. UNLIKE
512
+ * text-field/select, the grid ALREADY self-labels to its own month title
513
+ * (`aria-labelledby=titleId`, set once at `#ensureShell()`) — this override must MERGE the
514
+ * field's label ref into that existing relationship, never clobber it (the calendar always
515
+ * needs its own month/year context alongside whatever name the field supplies).
516
+ *
517
+ * `aria-describedby` has no other owner (the grid carries no internal validity-message node,
518
+ * unlike text-field's editor) — written from `[refs.description, refs.error]` when fielded,
519
+ * cleared on dissociation, in both branches below (the combo-box precedent, which has the same
520
+ * no-competing-owner shape).
521
+ *
522
+ * Guards a not-yet-created grid/title (the LLD-C2 override contract) — cannot happen in practice
523
+ * (`#ensureShell()` runs synchronously at the top of `connected()`, before the base's forwarding
524
+ * effect installs), but the guard costs nothing and documents the contract.
525
+ */
526
+ applyFieldLabelling(refs) {
527
+ const grid = this.#gridEl;
528
+ const title = this.#titleEl;
529
+ if (!grid || !title)
530
+ return;
531
+ grid.setAttribute('aria-labelledby', refs?.label ? `${refs.label.id} ${title.id}` : title.id);
532
+ if (refs === null) {
533
+ grid.removeAttribute('aria-describedby');
534
+ return;
535
+ }
536
+ const described = [refs.description, refs.error].filter((el) => el !== null);
537
+ if (described.length > 0)
538
+ grid.setAttribute('aria-describedby', described.map((el) => el.id).join(' '));
539
+ else
540
+ grid.removeAttribute('aria-describedby');
541
+ }
542
+ // ── Shell creation (idempotent) ───────────────────────────────────────────────────────────
543
+ #ensureShell() {
544
+ if (this.#panelEl && this.#gridEl && this.#prevBtn && this.#nextBtn && this.#titleEl && this.#statusEl) {
545
+ return { grid: this.#gridEl, prev: this.#prevBtn, next: this.#nextBtn };
546
+ }
547
+ // Panel — the [data-box] container (adopts the shared box-model: inset margins + sticky nav).
548
+ const panel = document.createElement('div');
549
+ panel.setAttribute('data-part', 'panel');
550
+ panel.setAttribute('data-box', '');
551
+ // Nav header — <header> is sticky + flex-row by container-box.css; we add `justify-content`.
552
+ const nav = document.createElement('header');
553
+ nav.setAttribute('data-part', 'nav');
554
+ const prev = document.createElement('button');
555
+ prev.setAttribute('data-part', 'prev');
556
+ prev.setAttribute('type', 'button');
557
+ prev.setAttribute('aria-label', 'Previous month');
558
+ setIcon(prev, 'caret-left'); // Phosphor, via @agent-ui-kit/icons
559
+ // Stable title id so [data-part=grid] can reference it via aria-labelledby.
560
+ const titleId = `ui-calendar-title-${++_nextCalendarId}`;
561
+ const title = document.createElement('span');
562
+ title.setAttribute('data-part', 'title');
563
+ title.setAttribute('id', titleId);
564
+ title.setAttribute('aria-live', 'polite');
565
+ // textContent filled by #rebuildGrid() on each display-month change.
566
+ const next = document.createElement('button');
567
+ next.setAttribute('data-part', 'next');
568
+ next.setAttribute('type', 'button');
569
+ next.setAttribute('aria-label', 'Next month');
570
+ setIcon(next, 'caret-right'); // Phosphor, via @agent-ui-kit/icons
571
+ nav.appendChild(prev);
572
+ nav.appendChild(title);
573
+ nav.appendChild(next);
574
+ // Grid container — host of the weekday header row + 6 week rows (all populated by #rebuildGrid).
575
+ const grid = document.createElement('div');
576
+ grid.setAttribute('data-part', 'grid');
577
+ grid.setAttribute('role', 'grid');
578
+ grid.setAttribute('aria-labelledby', titleId);
579
+ // ADR-0093 clause 3 — visually-hidden aria-live status region (range mode only). Always
580
+ // created (the idempotent-parts precedent — ui-select's aria-label span) so connected() never
581
+ // conditionally mints DOM; text stays empty (never written by #announce) in mode="single".
582
+ // Position:absolute (calendar.css) takes it out of flow — it does not disturb the [data-box]
583
+ // direct-child inset the panel/nav/grid share.
584
+ const status = document.createElement('div');
585
+ status.setAttribute('data-part', 'status');
586
+ status.setAttribute('aria-live', 'polite');
587
+ panel.appendChild(nav);
588
+ panel.appendChild(grid);
589
+ panel.appendChild(status);
590
+ this.appendChild(panel);
591
+ this.#panelEl = panel;
592
+ this.#gridEl = grid;
593
+ this.#titleEl = title;
594
+ this.#prevBtn = prev;
595
+ this.#nextBtn = next;
596
+ this.#statusEl = status;
597
+ return { grid, prev, next };
598
+ }
599
+ // ── Grid population ───────────────────────────────────────────────────────────────────────
600
+ /**
601
+ * Rebuild the entire grid for the currently-displayed month. Clears old rows and creates
602
+ * fresh DOM (weekday header row + 6 week rows × 7 cells). The focus cursor (#focusIso) is
603
+ * used to assign tabindex=0 via #computeFocusTarget.
604
+ *
605
+ * Called on: initial connect · prev/next nav · PageUp/Down · Arrow keys that cross month
606
+ * boundaries · click on an adjacent-month cell.
607
+ */
608
+ #rebuildGrid() {
609
+ const grid = this.#gridEl;
610
+ const title = this.#titleEl;
611
+ if (!grid || !title)
612
+ return;
613
+ const y = this.#displayYear;
614
+ const m = this.#displayMonth;
615
+ const today = this.#today();
616
+ const v = this.value;
617
+ const min = this.min;
618
+ const max = this.max;
619
+ const dis = this.effectiveDisabled();
620
+ const mode = this.mode;
621
+ const vs = this.valueStart;
622
+ const ve = this.valueEnd;
623
+ const preview = this.#previewIso;
624
+ const focusTarget = this.#computeFocusTarget();
625
+ // Update the month/year title (aria-live=polite announces to AT on change).
626
+ title.textContent = `${MONTH_NAMES[m - 1]} ${y}`;
627
+ // Clear existing content (idempotent: always rebuilds from scratch).
628
+ while (grid.firstChild)
629
+ grid.removeChild(grid.firstChild);
630
+ // Weekday column-header row: Su Mo Tu We Th Fr Sa.
631
+ const headerRow = document.createElement('div');
632
+ headerRow.setAttribute('role', 'row');
633
+ for (let i = 0; i < 7; i++) {
634
+ const th = document.createElement('span');
635
+ th.setAttribute('role', 'columnheader');
636
+ th.setAttribute('aria-label', WEEKDAY_FULL[i]);
637
+ th.setAttribute('abbr', WEEKDAY_FULL[i]);
638
+ th.textContent = WEEKDAY_SHORT[i];
639
+ headerRow.appendChild(th);
640
+ }
641
+ grid.appendChild(headerRow);
642
+ // Compute the first cell in the 6×7 grid. `firstDow` = day-of-week of the month's 1st
643
+ // (0=Sunday). `startDate` = that 1st minus its DOW offset (may be in the previous month).
644
+ // LOCAL Date construction — no timezone pitfall here (m is 1-based, m-1 is 0-based).
645
+ const firstDow = new Date(y, m - 1, 1).getDay();
646
+ const startDate = advanceDate(y, m, 1, -firstDow);
647
+ // 6 week rows × 7 day cells.
648
+ for (let row = 0; row < 6; row++) {
649
+ const rowEl = document.createElement('div');
650
+ rowEl.setAttribute('role', 'row');
651
+ for (let col = 0; col < 7; col++) {
652
+ const idx = row * 7 + col;
653
+ const { y: cy, m: cm, d: cd } = advanceDate(startDate.y, startDate.m, startDate.d, idx);
654
+ const iso = dateStr(cy, cm, cd);
655
+ const isOutside = (cy !== y || cm !== m);
656
+ const isToday = (cy === today.y && cm === today.m && cd === today.d);
657
+ const isDisabled = dis || isOutOfRange(iso, min, max);
658
+ // ADR-0093 clause 4 — range mode computes selection from the pair (+ preview candidate);
659
+ // single mode keeps the original single-value check. `range` is null in single mode —
660
+ // its data attributes are then never set below (grep-provable: single mode stamps none).
661
+ const range = mode === 'range' ? rangeSelectionFor(iso, vs, ve, preview) : null;
662
+ const isSelected = mode === 'range' ? range.selected : (iso === v && v !== '');
663
+ const cell = document.createElement('button');
664
+ cell.setAttribute('role', 'gridcell');
665
+ cell.setAttribute('type', 'button');
666
+ cell.setAttribute('tabindex', iso === focusTarget ? '0' : '-1');
667
+ cell.setAttribute('aria-label', `${MONTH_NAMES[cm - 1]} ${cd}, ${cy}`);
668
+ cell.setAttribute('aria-selected', isSelected ? 'true' : 'false');
669
+ if (isDisabled)
670
+ cell.setAttribute('aria-disabled', 'true');
671
+ if (isToday)
672
+ cell.dataset['today'] = '';
673
+ if (isOutside)
674
+ cell.dataset['outside'] = '';
675
+ if (range?.rangeStart)
676
+ cell.dataset['rangeStart'] = '';
677
+ if (range?.rangeEnd)
678
+ cell.dataset['rangeEnd'] = '';
679
+ if (range?.inRange)
680
+ cell.dataset['inRange'] = '';
681
+ cell.dataset['date'] = iso;
682
+ cell.textContent = String(cd);
683
+ rowEl.appendChild(cell);
684
+ }
685
+ grid.appendChild(rowEl);
686
+ }
687
+ }
688
+ /**
689
+ * Update ARIA + range data attributes (aria-selected / aria-disabled / data-today /
690
+ * data-range-start / data-range-end / data-in-range) on the existing cells in the current grid
691
+ * when a prop OR the preview candidate changes. No DOM rebuild — pure attribute mutation. Also
692
+ * re-syncs tabindex=0 to the computed focus target.
693
+ *
694
+ * Called from the scope-owned reactive effect (value/valueStart/valueEnd/mode/min/max/disabled)
695
+ * AND imperatively from the hover/keyboard preview paths (#refreshCellVisuals) — `mode`/
696
+ * `valueStart`/`valueEnd`/`previewIso` are passed explicitly rather than re-read internally so
697
+ * every call site is an honest snapshot of what it is rendering.
698
+ */
699
+ #updateCellStates(v, min, max, disabled, mode, valueStart, valueEnd, previewIso) {
700
+ const grid = this.#gridEl;
701
+ if (!grid)
702
+ return;
703
+ const today = this.#today();
704
+ const cells = grid.querySelectorAll('[role="gridcell"]');
705
+ const focusTarget = this.#computeFocusTarget();
706
+ for (const cell of cells) {
707
+ const iso = cell.dataset['date'];
708
+ if (!iso)
709
+ continue;
710
+ // ADR-0093 clause 4 — `range` is null in single mode, so every data-range-*/data-in-range
711
+ // branch below takes its `false` arm and the attribute is deleted (single mode stamps none).
712
+ const range = mode === 'range' ? rangeSelectionFor(iso, valueStart, valueEnd, previewIso) : null;
713
+ const isSelected = mode === 'range' ? range.selected : (iso === v && v !== '');
714
+ const isDisabled = disabled || isOutOfRange(iso, min, max);
715
+ const parsed = parseDateStr(iso);
716
+ const isToday = parsed
717
+ ? (parsed.y === today.y && parsed.m === today.m && parsed.d === today.d)
718
+ : false;
719
+ cell.setAttribute('aria-selected', isSelected ? 'true' : 'false');
720
+ if (isDisabled) {
721
+ cell.setAttribute('aria-disabled', 'true');
722
+ }
723
+ else {
724
+ cell.removeAttribute('aria-disabled');
725
+ }
726
+ if (isToday) {
727
+ cell.dataset['today'] = '';
728
+ }
729
+ else {
730
+ delete cell.dataset['today'];
731
+ }
732
+ if (range?.rangeStart)
733
+ cell.dataset['rangeStart'] = '';
734
+ else
735
+ delete cell.dataset['rangeStart'];
736
+ if (range?.rangeEnd)
737
+ cell.dataset['rangeEnd'] = '';
738
+ else
739
+ delete cell.dataset['rangeEnd'];
740
+ if (range?.inRange)
741
+ cell.dataset['inRange'] = '';
742
+ else
743
+ delete cell.dataset['inRange'];
744
+ cell.setAttribute('tabindex', iso === focusTarget ? '0' : '-1');
745
+ }
746
+ }
747
+ /** Recompute + apply cell visuals from the CURRENT prop/preview state (the hover/keyboard
748
+ * preview call site — outside the reactive effect, since `#previewIso` is not a signal). */
749
+ #refreshCellVisuals() {
750
+ this.#updateCellStates(this.value, this.min, this.max, this.effectiveDisabled(), this.mode, this.valueStart, this.valueEnd, this.#previewIso);
751
+ }
752
+ // ── Month navigation ──────────────────────────────────────────────────────────────────────
753
+ /**
754
+ * Navigate to a different month: update display state, update the focus cursor to the same
755
+ * day number (clamped to the new month's last day), rebuild the grid, restore DOM focus.
756
+ *
757
+ * Called by the prev/next buttons. Keyboard PageUp/Down navigates imperatively (they know
758
+ * the exact target day and call #rebuildGrid + #focusCurrentCell directly).
759
+ */
760
+ #navigateToMonth(y, m) {
761
+ const prevFocus = parseDateStr(this.#focusIso);
762
+ const day = prevFocus ? prevFocus.d : 1;
763
+ const clamped = Math.min(day, daysInMonth(y, m));
764
+ this.#displayYear = y;
765
+ this.#displayMonth = m;
766
+ this.#focusIso = dateStr(y, m, clamped);
767
+ this.#rebuildGrid();
768
+ this.#focusCurrentCell();
769
+ }
770
+ /** DOM-focus the cell that holds tabindex=0. No-op when the grid isn't connected. */
771
+ #focusCurrentCell() {
772
+ const cell = this.#gridEl?.querySelector('[tabindex="0"]');
773
+ cell?.focus();
774
+ }
775
+ // ── Bespoke 2D keyboard handler ───────────────────────────────────────────────────────────
776
+ #handleGridKey(event) {
777
+ // Only intercept grid-navigation / commit keys.
778
+ const key = event.key;
779
+ const NAV = new Set([
780
+ 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown',
781
+ 'Home', 'End', 'PageUp', 'PageDown', 'Enter', ' ',
782
+ ]);
783
+ if (!NAV.has(key))
784
+ return;
785
+ // Active cell must be inside the grid with role=gridcell.
786
+ const active = document.activeElement;
787
+ if (!active || active.getAttribute('role') !== 'gridcell')
788
+ return;
789
+ if (!this.#gridEl?.contains(active))
790
+ return;
791
+ const iso = active.dataset['date'];
792
+ if (!iso)
793
+ return;
794
+ const cur = parseDateStr(iso);
795
+ if (!cur)
796
+ return;
797
+ // preventDefault for ALL navigation/commit keys (suppresses scroll, form-submit,
798
+ // and — for Enter — the re-activation of an overlay anchor button, ADR-0048 §2).
799
+ event.preventDefault();
800
+ // ── Commit path (Enter / Space) ──────────────────────────────────────────────────────
801
+ if (key === 'Enter' || key === ' ') {
802
+ // No-op when the cell is disabled or out-of-range (aria-disabled is authoritative).
803
+ if (active.getAttribute('aria-disabled') !== 'true') {
804
+ this.#commitDate(cur.y, cur.m, cur.d);
805
+ }
806
+ return;
807
+ }
808
+ // ── Navigation path — compute the target date ────────────────────────────────────────
809
+ let target;
810
+ switch (key) {
811
+ case 'ArrowLeft':
812
+ target = advanceDate(cur.y, cur.m, cur.d, -1);
813
+ break;
814
+ case 'ArrowRight':
815
+ target = advanceDate(cur.y, cur.m, cur.d, 1);
816
+ break;
817
+ case 'ArrowUp':
818
+ target = advanceDate(cur.y, cur.m, cur.d, -7);
819
+ break;
820
+ case 'ArrowDown':
821
+ target = advanceDate(cur.y, cur.m, cur.d, 7);
822
+ break;
823
+ case 'Home': {
824
+ // Sunday of the current week.
825
+ const dow = new Date(cur.y, cur.m - 1, cur.d).getDay();
826
+ target = advanceDate(cur.y, cur.m, cur.d, -dow);
827
+ break;
828
+ }
829
+ case 'End': {
830
+ // Saturday of the current week.
831
+ const dow = new Date(cur.y, cur.m - 1, cur.d).getDay();
832
+ target = advanceDate(cur.y, cur.m, cur.d, 6 - dow);
833
+ break;
834
+ }
835
+ case 'PageUp': {
836
+ const delta = event.shiftKey ? -12 : -1;
837
+ const nm = addMonths(cur.y, cur.m, delta);
838
+ target = { ...nm, d: Math.min(cur.d, daysInMonth(nm.y, nm.m)) };
839
+ break;
840
+ }
841
+ case 'PageDown': {
842
+ const delta = event.shiftKey ? 12 : 1;
843
+ const nm = addMonths(cur.y, cur.m, delta);
844
+ target = { ...nm, d: Math.min(cur.d, daysInMonth(nm.y, nm.m)) };
845
+ break;
846
+ }
847
+ default:
848
+ return;
849
+ }
850
+ // Update the keyboard cursor.
851
+ this.#focusIso = dateStr(target.y, target.m, target.d);
852
+ // ADR-0093 clause 3 — keyboard-focus ALSO previews the in-progress band while selecting-end
853
+ // (the cursor IS the "which end am I editing" answer, same as hover). No-op outside
854
+ // mode="range" / outside selecting-end.
855
+ if (this.mode === 'range' && this.valueStart !== '' && this.valueEnd === '') {
856
+ this.#previewIso = this.#focusIso;
857
+ }
858
+ if (target.y === this.#displayYear && target.m === this.#displayMonth) {
859
+ // Target is in the current displayed month — refresh cell attributes (tabindex sync +
860
+ // aria-selected/range-preview) without a DOM rebuild.
861
+ this.#refreshCellVisuals();
862
+ this.#focusCurrentCell();
863
+ }
864
+ else {
865
+ // Different month — rebuild the grid for the new month then restore focus.
866
+ this.#displayYear = target.y;
867
+ this.#displayMonth = target.m;
868
+ this.#rebuildGrid();
869
+ this.#focusCurrentCell();
870
+ }
871
+ }
872
+ // ── Click handler ─────────────────────────────────────────────────────────────────────────
873
+ #handleGridClick(event) {
874
+ // Resolve the clicked cell via delegation (the click target may be the button or a child).
875
+ const hit = event.target;
876
+ const cell = hit?.closest('[role="gridcell"]');
877
+ if (!cell || !this.#gridEl?.contains(cell))
878
+ return;
879
+ const iso = cell.dataset['date'];
880
+ if (!iso)
881
+ return;
882
+ const parsed = parseDateStr(iso);
883
+ if (!parsed)
884
+ return;
885
+ // Update the keyboard cursor.
886
+ this.#focusIso = iso;
887
+ if (parsed.y !== this.#displayYear || parsed.m !== this.#displayMonth) {
888
+ // Adjacent-month cell: navigate to that month first (standard calendar UX).
889
+ this.#displayYear = parsed.y;
890
+ this.#displayMonth = parsed.m;
891
+ this.#rebuildGrid();
892
+ // Re-query the live cell after rebuild (old reference is gone).
893
+ const liveCell = this.#gridEl?.querySelector(`[data-date="${iso}"]`);
894
+ if (liveCell && liveCell.getAttribute('aria-disabled') !== 'true') {
895
+ this.#commitDate(parsed.y, parsed.m, parsed.d);
896
+ }
897
+ }
898
+ else {
899
+ // Same month: commit if not disabled.
900
+ if (cell.getAttribute('aria-disabled') !== 'true') {
901
+ this.#commitDate(parsed.y, parsed.m, parsed.d);
902
+ }
903
+ }
904
+ }
905
+ // ── Commit ────────────────────────────────────────────────────────────────────────────────
906
+ /**
907
+ * Commit a date selection — the ONE choke point both the click and Enter/Space paths call,
908
+ * which is exactly what makes it mode-aware without touching either caller (ADR-0093 clause 6:
909
+ * "`#commitDate` becomes mode-aware"). mode="single" runs the ORIGINAL, byte-identical path;
910
+ * mode="range" delegates to `#commitRangeDate` and NEVER touches `this.value` (clause 1).
911
+ *
912
+ * Emit semantics (ADR-0048 decision 2, reusing selectionCommit's learned semantics):
913
+ * · `change` — the value-binding event (value:{prop:'value', event:'change'} contract).
914
+ * · `select` — the selection event (detail = ISO string; usable for type=date overlay wiring).
915
+ * · Enter already called preventDefault() in #handleGridKey, so a calendar-in-an-overlay
916
+ * does not re-trigger the anchor button (the ADR-0045 / ADR-0048 commit semantics).
917
+ */
918
+ #commitDate(y, m, d) {
919
+ const iso = dateStr(y, m, d);
920
+ if (this.mode === 'range') {
921
+ this.#commitRangeDate(iso);
922
+ return;
923
+ }
924
+ const changed = iso !== this.value;
925
+ this.value = iso;
926
+ if (changed) {
927
+ this.emit('change');
928
+ this.emit('select', iso);
929
+ }
930
+ }
931
+ /**
932
+ * Range-mode commit (ADR-0093 clause 3 — swap-complete, single grid, two picks):
933
+ * · idle (both '') OR complete (both set, any order) → begin a FRESH anchor: valueStart=iso,
934
+ * valueEnd=''. A pick on an already-complete range starts a new one, discarding the old pair.
935
+ * · selecting-end (valueStart set, valueEnd '') → the SECOND commit ALWAYS completes as
936
+ * `[min,max]` of {iso, pendingStart} — a pick earlier than the pending start SWAP-completes
937
+ * (Kim's F2 ruling) via `normalizeRange` (lexicographic ISO compare, zero Date construction;
938
+ * the SAME function the preview uses, so the committed band is provably identical to what
939
+ * was previewed). Same-day second pick ⇒ lo===hi, a valid single-day range.
940
+ *
941
+ * `select` (detail = the raw picked iso) fires on EVERY commit; `change` fires ONLY on
942
+ * completion (mirrors the single-date `value` two-way-binding contract — clause 2). NEVER
943
+ * writes `this.value` — mode-inert by construction (clause 1).
944
+ */
945
+ #commitRangeDate(iso) {
946
+ const start = this.valueStart;
947
+ const end = this.valueEnd;
948
+ const isComplete = start !== '' && end !== '';
949
+ if (start === '' || isComplete) {
950
+ this.valueStart = iso;
951
+ this.valueEnd = '';
952
+ this.#previewIso = iso;
953
+ this.emit('select', iso);
954
+ this.#announce('Start date set — choose an end date.');
955
+ return;
956
+ }
957
+ const [lo, hi] = normalizeRange(iso, start);
958
+ this.valueStart = lo;
959
+ this.valueEnd = hi;
960
+ this.#previewIso = '';
961
+ this.emit('select', iso);
962
+ this.emit('change');
963
+ // Completion announcement names BOTH dates in order — a swapped completion is audible as the
964
+ // resulting (already-normalized) range (ADR-0093 clause 3).
965
+ this.#announce(`${formatIsoLong(lo)} to ${formatIsoLong(hi)} selected.`);
966
+ }
967
+ /** Write to the visually-hidden aria-live status region (ADR-0093 clause 3; range mode only —
968
+ * single mode's #commitDate path never calls this, so the region stays silent there). */
969
+ #announce(text) {
970
+ if (this.#statusEl)
971
+ this.#statusEl.textContent = text;
972
+ }
973
+ // ── Private helpers ───────────────────────────────────────────────────────────────────────
974
+ /** Today's local date (y/m/d integers). New Date() uses local timezone — correct for a date calendar. */
975
+ #today() {
976
+ const t = new Date();
977
+ return { y: t.getFullYear(), m: t.getMonth() + 1, d: t.getDate() };
978
+ }
979
+ /**
980
+ * Compute the ISO date that should hold tabindex=0. Priority (spec §Grid-decision):
981
+ * 1. #focusIso cursor, if it refers to a date in the currently displayed month.
982
+ * 2. The selected value, if it is in the currently displayed month.
983
+ * 3. Today, if today is in the currently displayed month.
984
+ * 4. The first day of the displayed month (fallback).
985
+ */
986
+ #computeFocusTarget() {
987
+ const y = this.#displayYear;
988
+ const m = this.#displayMonth;
989
+ const fp = parseDateStr(this.#focusIso);
990
+ if (fp && fp.y === y && fp.m === m)
991
+ return this.#focusIso;
992
+ if (this.value !== '') {
993
+ const vp = parseDateStr(this.value);
994
+ if (vp && vp.y === y && vp.m === m)
995
+ return this.value;
996
+ }
997
+ const today = this.#today();
998
+ if (today.y === y && today.m === m)
999
+ return dateStr(today.y, today.m, today.d);
1000
+ return dateStr(y, m, 1);
1001
+ }
1002
+ }
1003
+ if (!customElements.get('ui-calendar'))
1004
+ customElements.define('ui-calendar', UICalendarElement);