@adia-ai/web-components 0.6.33 → 0.6.34

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 (157) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/components/accordion/accordion.css +2 -2
  3. package/components/action-list/action-list.css +2 -2
  4. package/components/agent-artifact/agent-artifact.css +31 -31
  5. package/components/agent-feedback-bar/agent-feedback-bar.css +10 -10
  6. package/components/agent-questions/agent-questions.css +57 -57
  7. package/components/agent-reasoning/agent-reasoning.css +62 -62
  8. package/components/agent-suggestions/agent-suggestions.css +4 -4
  9. package/components/agent-trace/agent-trace.css +53 -53
  10. package/components/alert/alert.css +41 -41
  11. package/components/avatar/avatar.css +27 -27
  12. package/components/badge/badge.css +27 -27
  13. package/components/block/block.css +16 -16
  14. package/components/breadcrumb/breadcrumb.css +23 -23
  15. package/components/button/button.css +101 -91
  16. package/components/calendar-grid/calendar-grid.a2ui.json +136 -0
  17. package/components/calendar-grid/calendar-grid.css +226 -0
  18. package/components/calendar-grid/calendar-grid.d.ts +37 -0
  19. package/components/calendar-grid/calendar-grid.js +17 -0
  20. package/components/calendar-grid/calendar-grid.yaml +116 -0
  21. package/components/calendar-grid/class.js +300 -0
  22. package/components/calendar-picker/calendar-picker.css +139 -139
  23. package/components/canvas/canvas.css +12 -12
  24. package/components/card/card.css +83 -83
  25. package/components/chart/chart.css +224 -224
  26. package/components/chart-legend/chart-legend.css +26 -26
  27. package/components/check/check.css +40 -40
  28. package/components/code/code.css +125 -125
  29. package/components/col/col.css +15 -15
  30. package/components/color-picker/color-picker.css +55 -55
  31. package/components/combobox/class.js +861 -0
  32. package/components/combobox/combobox.a2ui.json +363 -0
  33. package/components/combobox/combobox.css +244 -0
  34. package/components/combobox/combobox.d.ts +113 -0
  35. package/components/combobox/combobox.examples.md +59 -0
  36. package/components/combobox/combobox.js +17 -0
  37. package/components/combobox/combobox.test.js +181 -0
  38. package/components/combobox/combobox.yaml +369 -0
  39. package/components/command/command.css +90 -90
  40. package/components/date-range-picker/class.js +775 -0
  41. package/components/date-range-picker/date-range-picker.a2ui.json +300 -0
  42. package/components/date-range-picker/date-range-picker.css +178 -0
  43. package/components/date-range-picker/date-range-picker.d.ts +82 -0
  44. package/components/date-range-picker/date-range-picker.examples.md +37 -0
  45. package/components/date-range-picker/date-range-picker.js +17 -0
  46. package/components/date-range-picker/date-range-picker.test.js +387 -0
  47. package/components/date-range-picker/date-range-picker.yaml +285 -0
  48. package/components/datetime-picker/class.js +706 -0
  49. package/components/datetime-picker/datetime-picker.a2ui.json +334 -0
  50. package/components/datetime-picker/datetime-picker.css +150 -0
  51. package/components/datetime-picker/datetime-picker.d.ts +86 -0
  52. package/components/datetime-picker/datetime-picker.examples.md +46 -0
  53. package/components/datetime-picker/datetime-picker.js +17 -0
  54. package/components/datetime-picker/datetime-picker.test.js +454 -0
  55. package/components/datetime-picker/datetime-picker.yaml +332 -0
  56. package/components/demo-toggle/demo-toggle.css +27 -27
  57. package/components/description-list/description-list.css +18 -18
  58. package/components/divider/divider.css +24 -24
  59. package/components/embed/embed.css +6 -6
  60. package/components/empty-state/empty-state.css +27 -27
  61. package/components/feed/feed.css +12 -12
  62. package/components/field/field.css +28 -28
  63. package/components/fields/fields.css +5 -5
  64. package/components/grid/grid.css +5 -5
  65. package/components/heatmap/heatmap.css +63 -63
  66. package/components/icon/icon.css +12 -12
  67. package/components/image/image.css +14 -14
  68. package/components/index.js +8 -0
  69. package/components/input/input.css +66 -66
  70. package/components/inspector/inspector.css +6 -6
  71. package/components/integration-card/class.js +410 -0
  72. package/components/integration-card/integration-card.a2ui.json +268 -0
  73. package/components/integration-card/integration-card.css +169 -0
  74. package/components/integration-card/integration-card.d.ts +63 -0
  75. package/components/integration-card/integration-card.examples.md +41 -0
  76. package/components/integration-card/integration-card.js +17 -0
  77. package/components/integration-card/integration-card.test.js +306 -0
  78. package/components/integration-card/integration-card.yaml +280 -0
  79. package/components/kbd/kbd.css +32 -32
  80. package/components/link/link.css +12 -12
  81. package/components/list/list.css +8 -8
  82. package/components/list-window/class.js +688 -0
  83. package/components/list-window/list-window.a2ui.json +277 -0
  84. package/components/list-window/list-window.css +124 -0
  85. package/components/list-window/list-window.d.ts +84 -0
  86. package/components/list-window/list-window.examples.md +73 -0
  87. package/components/list-window/list-window.js +17 -0
  88. package/components/list-window/list-window.test.js +303 -0
  89. package/components/list-window/list-window.yaml +270 -0
  90. package/components/menu/menu.css +8 -8
  91. package/components/modal/modal.css +43 -43
  92. package/components/nav/nav.css +40 -40
  93. package/components/nav-group/nav-group.css +52 -52
  94. package/components/nav-item/nav-item.css +44 -44
  95. package/components/noodles/noodles.css +31 -31
  96. package/components/option-card/option-card.css +69 -69
  97. package/components/otp-input/otp-input.css +30 -30
  98. package/components/page/page.css +18 -18
  99. package/components/pagination/pagination.css +61 -61
  100. package/components/pane/pane.css +57 -57
  101. package/components/pipeline-status/pipeline-status.css +65 -65
  102. package/components/popover/popover.css +17 -17
  103. package/components/progress/progress.css +23 -23
  104. package/components/progress-row/progress-row.css +17 -17
  105. package/components/radio/radio.css +39 -39
  106. package/components/range/range.css +55 -55
  107. package/components/rating/rating.css +28 -28
  108. package/components/richtext/richtext.css +133 -133
  109. package/components/row/row.css +19 -19
  110. package/components/search/search.css +5 -5
  111. package/components/segment/segment.css +24 -24
  112. package/components/segmented/segmented.css +25 -25
  113. package/components/select/select.css +84 -84
  114. package/components/skeleton/skeleton.css +14 -14
  115. package/components/slider/slider.css +46 -46
  116. package/components/spinner/class.js +69 -0
  117. package/components/spinner/spinner.a2ui.json +197 -0
  118. package/components/spinner/spinner.css +165 -0
  119. package/components/spinner/spinner.d.ts +26 -0
  120. package/components/spinner/spinner.examples.md +26 -0
  121. package/components/spinner/spinner.js +17 -0
  122. package/components/spinner/spinner.test.js +234 -0
  123. package/components/spinner/spinner.yaml +230 -0
  124. package/components/stack/stack.css +11 -11
  125. package/components/stat/stat.css +25 -25
  126. package/components/step-progress/step-progress.css +20 -20
  127. package/components/stepper/stepper.css +29 -29
  128. package/components/stream/stream.css +12 -12
  129. package/components/swatch/swatch.css +68 -68
  130. package/components/swiper/swiper.css +57 -57
  131. package/components/switch/switch.css +52 -52
  132. package/components/table/table.css +162 -162
  133. package/components/table-toolbar/table-toolbar.css +32 -32
  134. package/components/tabs/tabs.css +51 -51
  135. package/components/tag/tag.css +48 -48
  136. package/components/text/text.css +44 -44
  137. package/components/textarea/textarea.css +46 -46
  138. package/components/time-picker/class.js +693 -0
  139. package/components/time-picker/time-picker.a2ui.json +267 -0
  140. package/components/time-picker/time-picker.css +122 -0
  141. package/components/time-picker/time-picker.d.ts +75 -0
  142. package/components/time-picker/time-picker.examples.md +35 -0
  143. package/components/time-picker/time-picker.js +17 -0
  144. package/components/time-picker/time-picker.test.js +287 -0
  145. package/components/time-picker/time-picker.yaml +256 -0
  146. package/components/timeline/timeline.css +50 -50
  147. package/components/toast/toast.css +58 -58
  148. package/components/toggle-group/toggle-group.css +6 -6
  149. package/components/toggle-scheme/toggle-scheme.css +2 -2
  150. package/components/toolbar/toolbar.css +17 -17
  151. package/components/tooltip/tooltip.css +2 -2
  152. package/components/tree/tree.css +37 -37
  153. package/components/upload/upload.css +49 -49
  154. package/dist/web-components.min.css +1 -1
  155. package/dist/web-components.min.js +121 -83
  156. package/package.json +1 -1
  157. package/styles/components.css +8 -0
@@ -0,0 +1,706 @@
1
+ /**
2
+ * Non-side-effect class export for `<datetime-picker-ui>`.
3
+ *
4
+ * Importing this file gives you the class without auto-registering the tag.
5
+ * Useful for test isolation, subclassing with tag-name override, or selective
6
+ * composition.
7
+ *
8
+ * The auto-register path stays at `@adia-ai/web-components/components/datetime-picker`
9
+ * (which imports this file + calls `defineIfFree()`).
10
+ *
11
+ * @see ../../USAGE.md#registration--auto-vs-explicit
12
+ */
13
+
14
+ /**
15
+ * <datetime-picker-ui> — SPEC-038
16
+ *
17
+ * Single-control date + time selection. Composes:
18
+ * • <calendar-picker-ui> — the date pane (left)
19
+ * • <time-picker-ui> — the time pane (right; SPEC-043)
20
+ * • <popover-ui> — the floating panel surface (manual popover)
21
+ * • <button-ui> — default trigger + optional footer actions
22
+ * • <divider-ui> — visual separator between panes
23
+ *
24
+ * Form participation:
25
+ * ElementInternals serializes the picked ISO 8601 datetime ("YYYY-MM-
26
+ * DDTHH:mm" or "...:ss") as the form value under [name]. Per ADR-0025
27
+ * we never wrap a native <input type="datetime-local">; the calendar
28
+ * pane + time pane provide the affordance, ElementInternals provides
29
+ * the form contract.
30
+ *
31
+ * Keyboard model (composed from the underlying primitives):
32
+ * Enter/Space (trigger) — open popover; focus the calendar grid
33
+ * Escape (open) — close popover (no commit) + return focus
34
+ * Tab (open) — cycle trigger → calendar → time pane → footer
35
+ * Arrows (calendar) — delegated to <calendar-picker-ui> APG model
36
+ * Arrows (time segment) — delegated to <time-picker-ui> Spinbutton model
37
+ *
38
+ * A11y:
39
+ * role=combobox on host + aria-haspopup=dialog + aria-expanded.
40
+ * role=dialog on the popover. Focus is trapped while open; returns to
41
+ * trigger on close. The calendar pane and time pane each carry their
42
+ * own inherited WAI-APG model (Date Picker Dialog + Spinbutton).
43
+ *
44
+ * Static parts:
45
+ * trigger / popover / calPane / timePane / divider — all stamped on
46
+ * first connect via this.ensure(). No HTML template (light-DOM authored
47
+ * slot="trigger" or slot="footer" must survive render()).
48
+ */
49
+
50
+ import { UIFormElement } from '../../core/form.js';
51
+ import { anchorPopover } from '../../core/anchor.js';
52
+ import { untracked } from '../../core/signals.js';
53
+
54
+ const MONTHS_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
55
+ const MONTHS_LONG = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
56
+
57
+ function pad2(n) { return String(n).padStart(2, '0'); }
58
+
59
+ /** Parse "YYYY-MM-DDTHH:mm" or "YYYY-MM-DDTHH:mm:ss" → {date, time} or null. */
60
+ function parseDatetime(str) {
61
+ if (!str) return null;
62
+ const s = String(str).trim();
63
+ // Tolerate space separator as a Generative-UI quality-of-life affordance;
64
+ // canonical wire form is always "T".
65
+ const m = /^(\d{4}-\d{2}-\d{2})[T ](\d{1,2}:\d{2}(?::\d{2})?)$/.exec(s);
66
+ if (!m) return null;
67
+ return { date: m[1], time: m[2] };
68
+ }
69
+
70
+ /** Compose canonical "YYYY-MM-DDTHH:mm[:ss]" from a {date, time} pair. */
71
+ function formatDatetime(date, time, precision) {
72
+ if (!date || !time) return '';
73
+ // Coerce missing seconds when precision="second"; strip seconds otherwise.
74
+ const tm = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(time);
75
+ if (!tm) return '';
76
+ const hh = pad2(Number(tm[1]));
77
+ const mm = tm[2];
78
+ const ss = tm[3] || '00';
79
+ return precision === 'second' ? `${date}T${hh}:${mm}:${ss}` : `${date}T${hh}:${mm}`;
80
+ }
81
+
82
+ function formatDatePart(iso, fmt) {
83
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso || '');
84
+ if (!m) return '';
85
+ const y = +m[1];
86
+ const mo = +m[2] - 1;
87
+ const d = +m[3];
88
+ switch (fmt) {
89
+ case 'long': return `${MONTHS_LONG[mo]} ${d}, ${y}`;
90
+ case 'iso': return iso;
91
+ case 'short':
92
+ default: return `${MONTHS_SHORT[mo]} ${d}, ${y}`;
93
+ }
94
+ }
95
+
96
+ function formatTimePart(time, precision, hourcycle) {
97
+ const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(time || '');
98
+ if (!m) return '';
99
+ let h = +m[1];
100
+ const mm = m[2];
101
+ const ss = m[3];
102
+ // Display in the requested cycle (h12 derives display from 24h storage).
103
+ let hStr;
104
+ let meridiem = '';
105
+ if (hourcycle === 'h12') {
106
+ const isPM = h >= 12;
107
+ const dispH = (h % 12) || 12;
108
+ hStr = pad2(dispH);
109
+ meridiem = isPM ? ' PM' : ' AM';
110
+ } else {
111
+ hStr = pad2(h);
112
+ }
113
+ const sec = (precision === 'second' && ss != null) ? `:${ss}` : '';
114
+ return `${hStr}:${mm}${sec}${meridiem}`;
115
+ }
116
+
117
+ export class UIDatetimePicker extends UIFormElement {
118
+ // Per AGENTS.md / FEEDBACK guidance: label is NOT first-class here —
119
+ // use <field-ui label> wrap. Mark deprecated so the base warns once.
120
+ static labelDeprecated = true;
121
+
122
+ // §154: Phosphor icons this primitive auto-stamps (without consumer
123
+ // markup). Aggregated by installIconLoadersForRegistered() across all
124
+ // defined elements. Audited by check-required-icons.mjs.
125
+ static requiredIcons = ['calendar', 'clock', 'caret-down'];
126
+
127
+ static get properties() {
128
+ return {
129
+ ...UIFormElement.properties,
130
+ min: { type: String, default: '', reflect: true },
131
+ max: { type: String, default: '', reflect: true },
132
+ step: { type: Number, default: 60, reflect: true },
133
+ precision: { type: String, default: 'minute', reflect: true },
134
+ hourcycle: { type: String, default: '', reflect: true, attribute: 'hour-cycle' },
135
+ open: { type: Boolean, default: false, reflect: true },
136
+ placeholder: { type: String, default: 'Select date and time', reflect: false },
137
+ format: { type: String, default: 'short', reflect: true },
138
+ locale: { type: String, default: '', reflect: false },
139
+ };
140
+ }
141
+
142
+ // Static parts — fixed structural skeleton stamped on first connect.
143
+ // The popover is a `popover="manual"` element so we control
144
+ // showPopover() / hidePopover() programmatically. Mirrors
145
+ // date-range-picker's shape (the closest sibling).
146
+ static parts = {
147
+ trigger: '<button-ui slot="trigger" variant="outline" type="button" icon="calendar" trailing-icon="caret-down" aria-label="Open date and time picker"></button-ui>',
148
+ popover: '<div slot="popover" popover="manual" role="dialog" aria-label="Date and time picker"></div>',
149
+ // §FB-Wave1-QA — substrate primitive (extracted from calendar-picker-ui).
150
+ calPane: '<calendar-grid-ui data-cal-pane aria-label="Date"></calendar-grid-ui>',
151
+ divider: '<divider-ui data-pane-divider vertical></divider-ui>',
152
+ timePane: '<time-picker-ui data-time-pane></time-picker-ui>',
153
+ };
154
+
155
+ // No html`` template — light-DOM authored [slot="trigger"|"footer"]
156
+ // must survive render() (an empty html`` result triggers stamp() →
157
+ // replaceChildren() which would wipe authored slots).
158
+ static template = () => null;
159
+
160
+ // ── State ─────────────────────────────────────────────────────────
161
+ #pending = null; // partial {date, time} during selection
162
+ #previousFocus = null; // focus restoration target on close
163
+ #bound = false;
164
+ #popoverShown = false;
165
+ #triggerRef = null;
166
+ #popoverRef = null;
167
+ #calPaneRef = null;
168
+ #timePaneRef = null;
169
+ #dividerRef = null;
170
+ #anchorCleanup = null; // §FB-Wave1-QA — popover anchor positioning cleanup
171
+ // Flag set by #onTriggerKey to mark the *next* click as keyboard-origin.
172
+ // <button-ui> calls `this.click()` from its own keydown handler, so a
173
+ // click after Enter/Space is synthetic. Mirrors date-range-picker.
174
+ #keyboardOpen = false;
175
+
176
+ // ── Public accessors ──────────────────────────────────────────────
177
+
178
+ /** Parsed `{date, time}` object form of the value attribute (or null). */
179
+ get datetimeValue() {
180
+ return parseDatetime(this.value);
181
+ }
182
+ set datetimeValue(v) {
183
+ untracked(() => {
184
+ if (v == null) {
185
+ this.value = '';
186
+ } else if (typeof v === 'string') {
187
+ this.value = v;
188
+ } else if (v.date && v.time) {
189
+ this.value = formatDatetime(v.date, v.time, this.precision);
190
+ } else {
191
+ this.value = '';
192
+ }
193
+ });
194
+ }
195
+
196
+ // ── Imperative API ────────────────────────────────────────────────
197
+
198
+ /** Programmatically open the popover. Mirrors date-range-picker. */
199
+ openPopover() {
200
+ if (this.disabled || this.readonly) return;
201
+ if (!this.open) {
202
+ this.#previousFocus = document.activeElement;
203
+ this.open = true;
204
+ this.dispatchEvent(new CustomEvent('open', {
205
+ bubbles: true,
206
+ detail: { trigger: 'programmatic' },
207
+ }));
208
+ }
209
+ }
210
+
211
+ /** Programmatically close. */
212
+ closePopover(reason = 'programmatic') {
213
+ if (!this.open) return;
214
+ this.open = false;
215
+ this.dispatchEvent(new CustomEvent('close', {
216
+ bubbles: true,
217
+ detail: { reason },
218
+ }));
219
+ }
220
+
221
+ /** Reset value to empty string. */
222
+ clear() {
223
+ this.value = '';
224
+ this.#pending = null;
225
+ this.syncValue('');
226
+ }
227
+
228
+ // ── Constraint validation ─────────────────────────────────────────
229
+
230
+ /**
231
+ * Override `syncValue` to run datetime-specific constraints. The base
232
+ * UIFormElement::syncValue writes raw `value` to the form; we
233
+ * additionally validate against parseability + min / max.
234
+ */
235
+ syncValue(val) {
236
+ const baseVal = val ?? this.value ?? '';
237
+ this.internals.setFormValue(baseVal);
238
+ this.#runDatetimeConstraints(baseVal);
239
+ }
240
+
241
+ #runDatetimeConstraints(val) {
242
+ if (this.required && !val) {
243
+ this.internals.setValidity(
244
+ { valueMissing: true },
245
+ this.getAttribute('data-msg-required') || 'Please select a date and time.',
246
+ this,
247
+ );
248
+ return false;
249
+ }
250
+ if (!val) {
251
+ this.internals.setValidity({});
252
+ return true;
253
+ }
254
+ const parsed = parseDatetime(val);
255
+ if (!parsed) {
256
+ this.internals.setValidity(
257
+ { badInput: true },
258
+ 'Invalid date or time format.',
259
+ this,
260
+ );
261
+ return false;
262
+ }
263
+ if (this.min && val < this.min) {
264
+ this.internals.setValidity(
265
+ { rangeUnderflow: true },
266
+ `Earliest selectable datetime is ${this.min}.`,
267
+ this,
268
+ );
269
+ return false;
270
+ }
271
+ if (this.max && val > this.max) {
272
+ this.internals.setValidity(
273
+ { rangeOverflow: true },
274
+ `Latest selectable datetime is ${this.max}.`,
275
+ this,
276
+ );
277
+ return false;
278
+ }
279
+ this.internals.setValidity({});
280
+ return true;
281
+ }
282
+
283
+ // ── Lifecycle ─────────────────────────────────────────────────────
284
+
285
+ connected() {
286
+ super.connected();
287
+ this.setAttribute('role', 'combobox');
288
+ this.setAttribute('aria-haspopup', 'dialog');
289
+ this.setAttribute('aria-expanded', this.open ? 'true' : 'false');
290
+
291
+ if (!this.#bound) {
292
+ this.#bound = true;
293
+ this.#ensureParts();
294
+ this.#triggerRef.addEventListener('click', this.#onTriggerClick);
295
+ // Capture-phase so we flag keyboard-origin BEFORE <button-ui>'s
296
+ // own keydown synthesizes the click (button/class.js:148).
297
+ this.#triggerRef.addEventListener('keydown', this.#onTriggerKey, true);
298
+ this.#popoverRef.addEventListener('click', this.#onPopoverClick);
299
+ this.#popoverRef.addEventListener('keydown', this.#onPopoverKey);
300
+ this.#calPaneRef.addEventListener('change', this.#onCalPaneChange);
301
+ this.#timePaneRef.addEventListener('change', this.#onTimePaneChange);
302
+ this.#timePaneRef.addEventListener('input', this.#onTimePaneInput);
303
+ this.#timePaneRef.addEventListener('invalid', this.#onPaneInvalid);
304
+ }
305
+ this.syncValue();
306
+ }
307
+
308
+ disconnected() {
309
+ super.disconnected();
310
+ if (this.#triggerRef) {
311
+ this.#triggerRef.removeEventListener('click', this.#onTriggerClick);
312
+ this.#triggerRef.removeEventListener('keydown', this.#onTriggerKey, true);
313
+ }
314
+ if (this.#popoverRef) {
315
+ this.#popoverRef.removeEventListener('click', this.#onPopoverClick);
316
+ this.#popoverRef.removeEventListener('keydown', this.#onPopoverKey);
317
+ this.#popoverRef.hidePopover?.();
318
+ }
319
+ if (this.#calPaneRef) this.#calPaneRef.removeEventListener('change', this.#onCalPaneChange);
320
+ if (this.#timePaneRef) {
321
+ this.#timePaneRef.removeEventListener('change', this.#onTimePaneChange);
322
+ this.#timePaneRef.removeEventListener('input', this.#onTimePaneInput);
323
+ this.#timePaneRef.removeEventListener('invalid', this.#onPaneInvalid);
324
+ }
325
+ document.removeEventListener('pointerdown', this.#onOutside);
326
+ document.removeEventListener('keydown', this.#onDocKey);
327
+ this.#anchorCleanup?.();
328
+ this.#anchorCleanup = null;
329
+ this.#bound = false;
330
+ this.#popoverShown = false;
331
+ this.#triggerRef = null;
332
+ this.#popoverRef = null;
333
+ this.#calPaneRef = null;
334
+ this.#timePaneRef = null;
335
+ this.#dividerRef = null;
336
+ this.#pending = null;
337
+ }
338
+
339
+ #ensureParts() {
340
+ // Trigger — light DOM. Default <button-ui> stamped unless consumer
341
+ // supplied [slot="trigger"].
342
+ this.#triggerRef = this.ensure('trigger');
343
+ // Popover is a div hosted in light DOM but lifted to the top layer
344
+ // via the Popover API on showPopover().
345
+ this.#popoverRef = this.ensure('popover');
346
+ if (this.id) this.#popoverRef.setAttribute('aria-labelledby', this.id);
347
+
348
+ // Stamp the two panes + divider inside the popover (idempotent).
349
+ if (!this.#popoverRef.querySelector(':scope > [data-cal-pane]')) {
350
+ const cal = this.constructor._pp.calPane.cloneNode(true);
351
+ this.#popoverRef.appendChild(cal);
352
+ }
353
+ if (!this.#popoverRef.querySelector(':scope > [data-pane-divider]')) {
354
+ const div = this.constructor._pp.divider.cloneNode(true);
355
+ this.#popoverRef.appendChild(div);
356
+ }
357
+ if (!this.#popoverRef.querySelector(':scope > [data-time-pane]')) {
358
+ const time = this.constructor._pp.timePane.cloneNode(true);
359
+ this.#popoverRef.appendChild(time);
360
+ }
361
+ this.#calPaneRef = this.#popoverRef.querySelector(':scope > [data-cal-pane]');
362
+ this.#dividerRef = this.#popoverRef.querySelector(':scope > [data-pane-divider]');
363
+ this.#timePaneRef = this.#popoverRef.querySelector(':scope > [data-time-pane]');
364
+
365
+ // Footer slot — consumer-supplied; lift into popover after the panes.
366
+ const authorFooter = this.querySelector(':scope > [slot="footer"]');
367
+ if (authorFooter && authorFooter.parentElement !== this.#popoverRef) {
368
+ this.#popoverRef.appendChild(authorFooter);
369
+ }
370
+ }
371
+
372
+ render() {
373
+ if (!this.#triggerRef) return;
374
+ // Re-stamp parts if they were wiped by a consumer innerHTML write
375
+ // between the last connect and this render (FEEDBACK-31 invariant).
376
+ if (!this.#calPaneRef || !this.#timePaneRef || !this.#calPaneRef.isConnected) {
377
+ this.#ensureParts();
378
+ }
379
+
380
+ // Trigger reflection — text, disabled, ARIA expansion.
381
+ this.#triggerRef.setAttribute('text', this.#displayText());
382
+ this.#triggerRef.toggleAttribute('disabled', this.disabled);
383
+ this.#triggerRef.setAttribute('aria-haspopup', 'dialog');
384
+ this.#triggerRef.setAttribute('aria-expanded', this.open ? 'true' : 'false');
385
+ this.setAttribute('aria-expanded', this.open ? 'true' : 'false');
386
+
387
+ // Cascade value + constraints into the two panes.
388
+ const parsed = parseDatetime(this.value);
389
+ const pending = this.#pending;
390
+ const datePart = pending?.date ?? parsed?.date ?? '';
391
+ const timePart = pending?.time ?? parsed?.time ?? '';
392
+
393
+ if (this.#calPaneRef) {
394
+ // Reflect min/max date portions onto the calendar pane.
395
+ const minDate = this.min ? this.min.slice(0, 10) : '';
396
+ const maxDate = this.max ? this.max.slice(0, 10) : '';
397
+ if (minDate) this.#calPaneRef.setAttribute('min', minDate);
398
+ else this.#calPaneRef.removeAttribute('min');
399
+ if (maxDate) this.#calPaneRef.setAttribute('max', maxDate);
400
+ else this.#calPaneRef.removeAttribute('max');
401
+ if (this.#calPaneRef.value !== datePart) {
402
+ this.#calPaneRef.value = datePart;
403
+ }
404
+ this.#calPaneRef.toggleAttribute('disabled', !!this.disabled);
405
+ this.#calPaneRef.toggleAttribute('readonly', !!this.readonly);
406
+ }
407
+
408
+ if (this.#timePaneRef) {
409
+ this.#timePaneRef.setAttribute('precision', this.precision);
410
+ this.#timePaneRef.setAttribute('step', String(this.step));
411
+ if (this.hourcycle) this.#timePaneRef.setAttribute('hour-cycle', this.hourcycle);
412
+ else this.#timePaneRef.removeAttribute('hour-cycle');
413
+ if (this.locale) this.#timePaneRef.setAttribute('locale', this.locale);
414
+ else this.#timePaneRef.removeAttribute('locale');
415
+ // Min/max time portions only apply when date matches the bound's
416
+ // date. For v1 we cascade the time-of-day portion of the bound
417
+ // unconditionally; the host-level invalid check rejects out-of-
418
+ // range commits, so the time pane bound is an advisory affordance.
419
+ const minTime = this.min ? this.min.slice(11) : '';
420
+ const maxTime = this.max ? this.max.slice(11) : '';
421
+ if (minTime && datePart === this.min.slice(0, 10)) this.#timePaneRef.setAttribute('min', minTime);
422
+ else this.#timePaneRef.removeAttribute('min');
423
+ if (maxTime && datePart === this.max.slice(0, 10)) this.#timePaneRef.setAttribute('max', maxTime);
424
+ else this.#timePaneRef.removeAttribute('max');
425
+ if (this.#timePaneRef.value !== timePart) {
426
+ this.#timePaneRef.value = timePart;
427
+ }
428
+ this.#timePaneRef.toggleAttribute('disabled', !!this.disabled);
429
+ this.#timePaneRef.toggleAttribute('readonly', !!this.readonly);
430
+ }
431
+
432
+ // Toggle popover via the Popover API. Mirrors date-range-picker.
433
+ if (this.#popoverRef) {
434
+ if (this.open && !this.#popoverShown) {
435
+ if (!this.disabled && !this.readonly) {
436
+ this.#popoverShown = true;
437
+ this.#popoverRef.showPopover?.();
438
+ // §FB-Wave1-QA — anchor the popover to the trigger via the canonical
439
+ // helper. Without this, the popover renders at viewport (0,0).
440
+ this.#anchorCleanup?.();
441
+ this.#anchorCleanup = anchorPopover(this.#triggerRef, this.#popoverRef, {
442
+ placement: this.getAttribute('placement') || 'bottom-start',
443
+ gap: 4,
444
+ });
445
+ document.addEventListener('pointerdown', this.#onOutside);
446
+ document.addEventListener('keydown', this.#onDocKey);
447
+ queueMicrotask(() => {
448
+ if (!this.open) return;
449
+ // Focus the calendar grid first (date-pending state).
450
+ const grid = this.#calPaneRef?.querySelector?.('[data-cal-day]:not([disabled]):not([data-outside])');
451
+ grid?.focus?.();
452
+ });
453
+ } else {
454
+ this.open = false;
455
+ }
456
+ } else if (!this.open && this.#popoverShown) {
457
+ this.#popoverShown = false;
458
+ this.#anchorCleanup?.();
459
+ this.#anchorCleanup = null;
460
+ this.#popoverRef.hidePopover?.();
461
+ document.removeEventListener('pointerdown', this.#onOutside);
462
+ document.removeEventListener('keydown', this.#onDocKey);
463
+ this.#triggerRef?.focus?.();
464
+ }
465
+ }
466
+ }
467
+
468
+ // ── Trigger text formatter ────────────────────────────────────────
469
+
470
+ #displayText() {
471
+ const parsed = parseDatetime(this.value);
472
+ if (!parsed) return this.placeholder;
473
+ const dPart = formatDatePart(parsed.date, this.format);
474
+ const tPart = formatTimePart(parsed.time, this.precision, this.hourcycle);
475
+ if (!dPart || !tPart) return this.placeholder;
476
+ return `${dPart} ${tPart}`;
477
+ }
478
+
479
+ // ── Event handlers ────────────────────────────────────────────────
480
+
481
+ #onTriggerClick = (e) => {
482
+ if (this.disabled) return;
483
+ if (this.readonly) return;
484
+ e.stopPropagation();
485
+ const fromKeyboard = this.#keyboardOpen;
486
+ this.#keyboardOpen = false;
487
+ if (this.open) {
488
+ this.closePopover('outside');
489
+ } else {
490
+ this.#previousFocus = document.activeElement;
491
+ this.open = true;
492
+ this.dispatchEvent(new CustomEvent('open', {
493
+ bubbles: true,
494
+ detail: { trigger: fromKeyboard ? 'keyboard' : 'click' },
495
+ }));
496
+ }
497
+ };
498
+
499
+ #onTriggerKey = (e) => {
500
+ if (this.disabled) return;
501
+ if (e.key === 'Enter' || e.key === ' ') {
502
+ if (this.readonly) {
503
+ e.preventDefault();
504
+ return;
505
+ }
506
+ this.#keyboardOpen = true;
507
+ }
508
+ };
509
+
510
+ #onPopoverClick = (e) => {
511
+ // Footer button handling — Apply / Cancel semantics if the consumer
512
+ // wires data-action attrs onto authored slot="footer" buttons.
513
+ const actionBtn = e.target.closest('[data-action]');
514
+ if (actionBtn && this.contains(actionBtn)) {
515
+ const action = actionBtn.dataset.action;
516
+ if (action === 'apply') {
517
+ this.#commitFromPanes('click');
518
+ this.closePopover('apply');
519
+ } else if (action === 'cancel') {
520
+ // Discard pending; restore from value.
521
+ this.#pending = null;
522
+ this.closePopover('cancel');
523
+ }
524
+ }
525
+ };
526
+
527
+ #onPopoverKey = (e) => {
528
+ if (e.key === 'Escape') {
529
+ e.preventDefault();
530
+ e.stopPropagation();
531
+ this.closePopover('escape');
532
+ return;
533
+ }
534
+ if (e.key === 'Tab') {
535
+ // Focus trap — cycle inside the popover. Trigger is outside the
536
+ // popover; Tab cycles inside until Escape closes.
537
+ const focusables = this.#getFocusables();
538
+ if (focusables.length === 0) return;
539
+ const idx = focusables.indexOf(document.activeElement);
540
+ if (e.shiftKey) {
541
+ if (idx <= 0) {
542
+ e.preventDefault();
543
+ focusables[focusables.length - 1].focus();
544
+ }
545
+ } else {
546
+ if (idx === -1 || idx === focusables.length - 1) {
547
+ e.preventDefault();
548
+ focusables[0].focus();
549
+ }
550
+ }
551
+ }
552
+ };
553
+
554
+ #onDocKey = (e) => {
555
+ // Belt-and-braces: Escape closes even when focus is outside (e.g.,
556
+ // when the trigger absorbs it after some interaction).
557
+ if (e.key === 'Escape' && this.open) {
558
+ this.closePopover('escape');
559
+ }
560
+ };
561
+
562
+ #onCalPaneChange = (e) => {
563
+ if (this.readonly) return;
564
+ e.stopPropagation();
565
+ const iso = e.detail?.value || this.#calPaneRef?.value || '';
566
+ if (!iso) return;
567
+ if (!this.#pending) this.#pending = { date: '', time: '' };
568
+ this.#pending.date = iso;
569
+ // Live emit `input` for partial state.
570
+ const existing = parseDatetime(this.value);
571
+ const timeCandidate = this.#pending.time || existing?.time || '';
572
+ const partial = timeCandidate
573
+ ? formatDatetime(iso, timeCandidate, this.precision)
574
+ : `${iso}T`;
575
+ this.dispatchEvent(new CustomEvent('input', {
576
+ bubbles: true,
577
+ detail: { value: partial },
578
+ }));
579
+ if (timeCandidate) {
580
+ this.#commitFromParts({ date: iso, time: timeCandidate });
581
+ } else {
582
+ // Advance focus to the time pane's first segment for the
583
+ // pending-time state (a11y model: date → time → commit).
584
+ queueMicrotask(() => {
585
+ if (!this.open) return;
586
+ const hourSeg = this.#timePaneRef?.querySelector?.('[data-segment="hour"]');
587
+ hourSeg?.focus?.();
588
+ });
589
+ }
590
+ };
591
+
592
+ #onTimePaneChange = (e) => {
593
+ if (this.readonly) return;
594
+ e.stopPropagation();
595
+ const time = e.detail?.value || this.#timePaneRef?.value || '';
596
+ if (!time) return;
597
+ if (!this.#pending) this.#pending = { date: '', time: '' };
598
+ this.#pending.time = time;
599
+ const existing = parseDatetime(this.value);
600
+ const dateCandidate = this.#pending.date || existing?.date || '';
601
+ if (dateCandidate) {
602
+ this.#commitFromParts({ date: dateCandidate, time });
603
+ } else {
604
+ // Time chosen without a date — partial state.
605
+ this.dispatchEvent(new CustomEvent('input', {
606
+ bubbles: true,
607
+ detail: { value: `T${time}` },
608
+ }));
609
+ }
610
+ };
611
+
612
+ #onTimePaneInput = (e) => {
613
+ if (this.readonly) return;
614
+ e.stopPropagation();
615
+ // Surface the time-pane's intermediate edits as host `input` events
616
+ // so consumers see a unified stream.
617
+ const time = e.detail?.value || '';
618
+ const existing = parseDatetime(this.value);
619
+ const date = this.#pending?.date || existing?.date || '';
620
+ const partial = (date && time) ? formatDatetime(date, time, this.precision) : (time ? `T${time}` : '');
621
+ this.dispatchEvent(new CustomEvent('input', {
622
+ bubbles: true,
623
+ detail: { value: partial },
624
+ }));
625
+ };
626
+
627
+ #onPaneInvalid = (e) => {
628
+ e.stopPropagation();
629
+ this.dispatchEvent(new CustomEvent('invalid', {
630
+ bubbles: true,
631
+ detail: { value: this.value, reason: e.detail?.reason || 'pane' },
632
+ }));
633
+ };
634
+
635
+ #onOutside = (e) => {
636
+ if (!this.open) return;
637
+ if (this.contains(e.target)) return;
638
+ if (this.#popoverRef && e.composedPath?.().includes(this.#popoverRef)) return;
639
+ this.closePopover('outside');
640
+ };
641
+
642
+ // ── Commit ────────────────────────────────────────────────────────
643
+
644
+ /**
645
+ * Commit a candidate {date, time} pair as the new value if it passes
646
+ * constraint validation. Mirrors date-range-picker's #commitRange but
647
+ * for a single datetime string.
648
+ */
649
+ #commitFromParts(parts, source = 'click') {
650
+ if (!parts || !parts.date || !parts.time) return;
651
+ const next = formatDatetime(parts.date, parts.time, this.precision);
652
+ if (!next) {
653
+ this.dispatchEvent(new CustomEvent('invalid', {
654
+ bubbles: true,
655
+ detail: { value: next, reason: 'parse' },
656
+ }));
657
+ return;
658
+ }
659
+ if (this.min && next < this.min) {
660
+ this.dispatchEvent(new CustomEvent('invalid', {
661
+ bubbles: true,
662
+ detail: { value: next, reason: 'below-min' },
663
+ }));
664
+ return;
665
+ }
666
+ if (this.max && next > this.max) {
667
+ this.dispatchEvent(new CustomEvent('invalid', {
668
+ bubbles: true,
669
+ detail: { value: next, reason: 'above-max' },
670
+ }));
671
+ return;
672
+ }
673
+ this.value = next;
674
+ this.#pending = null;
675
+ this.syncValue(next);
676
+ this.dispatchEvent(new CustomEvent('change', {
677
+ bubbles: true,
678
+ detail: { value: next, source },
679
+ }));
680
+ }
681
+
682
+ /**
683
+ * Commit using the panes' current values (Apply path).
684
+ */
685
+ #commitFromPanes(source = 'click') {
686
+ const date = this.#calPaneRef?.value || '';
687
+ const time = this.#timePaneRef?.value || '';
688
+ if (!date || !time) return;
689
+ this.#commitFromParts({ date, time }, source);
690
+ }
691
+
692
+ #getFocusables() {
693
+ if (!this.#popoverRef) return [];
694
+ const SEL = [
695
+ 'button-ui:not([disabled])',
696
+ 'button:not([disabled])',
697
+ 'calendar-grid-ui:not([disabled])',
698
+ 'time-picker-ui:not([disabled])',
699
+ '[data-cal-day]:not([disabled]):not([data-outside])',
700
+ '[data-segment]',
701
+ '[tabindex="0"]',
702
+ ].join(',');
703
+ return Array.from(this.#popoverRef.querySelectorAll(SEL))
704
+ .filter((el) => el.offsetParent !== null || el.matches(':popover-open *'));
705
+ }
706
+ }