@adia-ai/web-modules 0.8.36 → 0.8.37

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 (89) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +3 -3
  3. package/agent-admin/admin-roster/admin-roster.js +1 -1
  4. package/agent-admin/admin-settings/admin-settings.js +1 -1
  5. package/agent-admin/agent-admin/agent-admin.js +1 -1
  6. package/billing/billing-overview/billing-overview.a2ui.json +281 -0
  7. package/billing/billing-overview/billing-overview.class.js +838 -0
  8. package/billing/billing-overview/billing-overview.css +223 -0
  9. package/billing/billing-overview/billing-overview.js +16 -0
  10. package/billing/billing-overview/billing-overview.yaml +441 -0
  11. package/billing/index.js +21 -0
  12. package/billing/invoice-detail/invoice-detail.a2ui.json +276 -0
  13. package/billing/invoice-detail/invoice-detail.class.js +711 -0
  14. package/billing/invoice-detail/invoice-detail.css +305 -0
  15. package/billing/invoice-detail/invoice-detail.js +16 -0
  16. package/billing/invoice-detail/invoice-detail.yaml +411 -0
  17. package/billing/invoice-history/invoice-history.a2ui.json +315 -0
  18. package/billing/invoice-history/invoice-history.class.js +672 -0
  19. package/billing/invoice-history/invoice-history.css +130 -0
  20. package/billing/invoice-history/invoice-history.js +16 -0
  21. package/billing/invoice-history/invoice-history.yaml +398 -0
  22. package/billing/payment-method-form/payment-method-form.a2ui.json +310 -0
  23. package/billing/payment-method-form/payment-method-form.class.js +886 -0
  24. package/billing/payment-method-form/payment-method-form.css +149 -0
  25. package/billing/payment-method-form/payment-method-form.js +16 -0
  26. package/billing/payment-method-form/payment-method-form.yaml +430 -0
  27. package/billing/payment-method-list/payment-method-list.a2ui.json +283 -0
  28. package/billing/payment-method-list/payment-method-list.class.js +705 -0
  29. package/billing/payment-method-list/payment-method-list.css +128 -0
  30. package/billing/payment-method-list/payment-method-list.js +16 -0
  31. package/billing/payment-method-list/payment-method-list.yaml +355 -0
  32. package/billing/plan-picker/plan-picker.a2ui.json +349 -0
  33. package/billing/plan-picker/plan-picker.class.js +635 -0
  34. package/billing/plan-picker/plan-picker.css +227 -0
  35. package/billing/plan-picker/plan-picker.js +16 -0
  36. package/billing/plan-picker/plan-picker.yaml +453 -0
  37. package/chat/README.md +1 -1
  38. package/chat/chat-composer/chat-composer.js +2 -2
  39. package/chat/chat-empty/chat-empty.js +2 -2
  40. package/chat/chat-shell/chat-shell.js +4 -4
  41. package/chat/chat-sidebar/chat-sidebar.js +2 -2
  42. package/chat/chat-surfaces/chat-surfaces.js +2 -2
  43. package/chat/chat-thread/chat-thread.js +2 -2
  44. package/dashboard/dashboard-layout/dashboard-layout.a2ui.json +254 -0
  45. package/dashboard/dashboard-layout/dashboard-layout.css +190 -0
  46. package/dashboard/dashboard-layout/dashboard-layout.js +289 -0
  47. package/dashboard/dashboard-layout/dashboard-layout.yaml +374 -0
  48. package/dashboard/date-range-selector/date-range-selector.a2ui.json +236 -0
  49. package/dashboard/date-range-selector/date-range-selector.css +84 -0
  50. package/dashboard/date-range-selector/date-range-selector.js +686 -0
  51. package/dashboard/date-range-selector/date-range-selector.yaml +358 -0
  52. package/dashboard/index.js +14 -0
  53. package/dist/everything.min.js +1 -1
  54. package/editor/editor-canvas/editor-canvas.js +2 -2
  55. package/editor/editor-shell/editor-shell.js +2 -2
  56. package/editor/editor-sidebar/editor-sidebar.js +2 -2
  57. package/editor/editor-toolbar/editor-toolbar.js +2 -2
  58. package/feedback/confirm-dialog/confirm-dialog.a2ui.json +206 -0
  59. package/feedback/confirm-dialog/confirm-dialog.css +157 -0
  60. package/feedback/confirm-dialog/confirm-dialog.js +383 -0
  61. package/feedback/confirm-dialog/confirm-dialog.yaml +297 -0
  62. package/feedback/index.js +10 -0
  63. package/form/form-popover/form-popover.js +1 -1
  64. package/generative/index.d.ts +1 -1
  65. package/generative/index.js +2 -2
  66. package/onboarding/index.js +1 -0
  67. package/onboarding/onboarding-checklist/onboarding-checklist.a2ui.json +263 -0
  68. package/onboarding/onboarding-checklist/onboarding-checklist.css +157 -0
  69. package/onboarding/onboarding-checklist/onboarding-checklist.js +428 -0
  70. package/onboarding/onboarding-checklist/onboarding-checklist.yaml +236 -0
  71. package/package.json +99 -6
  72. package/runtime/a2ui-root/a2ui-root.js +3 -3
  73. package/runtime/gen-root/gen-root.js +5 -5
  74. package/settings/index.js +12 -0
  75. package/settings/integrations-page/integrations-page.a2ui.json +274 -0
  76. package/settings/integrations-page/integrations-page.css +177 -0
  77. package/settings/integrations-page/integrations-page.d.ts +77 -0
  78. package/settings/integrations-page/integrations-page.js +413 -0
  79. package/settings/integrations-page/integrations-page.yaml +317 -0
  80. package/settings/notification-preferences/notification-preferences.a2ui.json +245 -0
  81. package/settings/notification-preferences/notification-preferences.css +204 -0
  82. package/settings/notification-preferences/notification-preferences.js +622 -0
  83. package/settings/notification-preferences/notification-preferences.yaml +358 -0
  84. package/shell/admin-command/admin-command.js +2 -2
  85. package/shell/admin-shell/admin-shell.js +2 -2
  86. package/shell/admin-sidebar/admin-sidebar.js +2 -2
  87. package/shell/embed-shell/embed-shell.js +3 -3
  88. package/simple/simple-shell/simple-shell.js +1 -1
  89. package/theme/theme-panel/theme-panel.js +2 -2
@@ -0,0 +1,686 @@
1
+ /**
2
+ * <date-range-selector-ui> — Page-level time-window chooser for dashboards.
3
+ *
4
+ * A module-tier composite that pairs a preset chip-row (Today / 7d / 30d /
5
+ * 90d / QTD / YTD / Custom) with an embedded <date-range-picker-ui>
6
+ * (SPEC-037) and broadcasts the resolved range to range-aware panels on
7
+ * the page via a documented CustomEvent + reflected attributes.
8
+ *
9
+ * SPEC: .claude/docs/specs/implementation-ready/SPEC-019-date-range-selector-global.md
10
+ * Composed primitives (consumer must side-effect import — ADR-0027):
11
+ * - <segmented-ui> + <segment-ui> (preset chip row)
12
+ * - <date-range-picker-ui> (custom-range editor, Wave-1 primitive)
13
+ * - <text-ui> (resolved-range readout)
14
+ *
15
+ * Why a composite (not a primitive subclassing segmented-ui):
16
+ * The composite owns three layered surfaces — preset chip row, picker,
17
+ * readout — plus the broadcast bus. None of these belong inside
18
+ * segmented-ui (which is a generic single-select group). The composite
19
+ * stamps each piece in its own light-DOM region and reflects per-region
20
+ * attributes for CSS layout. Per ADR-0033 (Light-DOM substrate) we
21
+ * rely on tag-and-ancestor selectors; no shadow DOM.
22
+ *
23
+ * Broadcast contract (§8):
24
+ * - `range-change` fires ONCE per resolved range on the broadcast target
25
+ * ({document.documentElement, host element, or none} per [broadcast]).
26
+ * - `from <= to` always; the composite swaps reversed ranges before commit.
27
+ * - ISO-8601 strings (YYYY-MM-DD) on the wire; no Date instances.
28
+ * - When [broadcast=document], writes data-range-from / data-range-to
29
+ * to <html>; one-broadcaster-per-page invariant enforced via a
30
+ * module-level Set + console.warn on conflict.
31
+ *
32
+ * Form participation:
33
+ * When [name] is set, ElementInternals serializes the range as a
34
+ * colon-pair "{from}:{to}" (e.g. "2026-05-01:2026-05-07") under [name].
35
+ * Without [name], the composite emits events only.
36
+ */
37
+
38
+ import { UIFormElement } from '@adia-ai/web-components/core/form';
39
+ import { untracked } from '@adia-ai/web-components/core/signals';
40
+
41
+ // ── Module-level invariant tracking ──
42
+ //
43
+ // SPEC §3.3 / §6 anti-pattern: only one document-broadcaster per page is
44
+ // permitted. Two instances racing for data-range-* on <html> produce
45
+ // flicker. We track the set of currently-mounted document-broadcasters
46
+ // and emit a one-shot console.warn when a second one connects.
47
+
48
+ /** Set of currently-mounted instances with broadcast="document". */
49
+ const documentBroadcasters = new Set();
50
+
51
+ /** One-shot warn flag — keyed per page lifetime, not per instance. */
52
+ let multiBroadcasterWarned = false;
53
+
54
+ // ── Preset resolver ──
55
+ //
56
+ // Each preset key resolves to a {from, to} ISO pair against "today" (the
57
+ // resolver is called lazily on each range commit so the user's clock
58
+ // advancing across midnight is reflected). Keys not in this table are
59
+ // silently ignored (the chip row renders only recognized keys).
60
+
61
+ function pad(n) { return String(n).padStart(2, '0'); }
62
+
63
+ function toISO(d) {
64
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
65
+ }
66
+
67
+ function parseISO(str) {
68
+ if (!str) return null;
69
+ const m = String(str).match(/^(\d{4})-(\d{2})-(\d{2})$/);
70
+ if (!m) return null;
71
+ const d = new Date(+m[1], +m[2] - 1, +m[3]);
72
+ return Number.isNaN(d.getTime()) ? null : d;
73
+ }
74
+
75
+ /** Default labels for the preset chips. Consumers override via [presets]. */
76
+ const PRESET_LABELS = {
77
+ today: 'Today',
78
+ '7d': 'Last 7 days',
79
+ '30d': 'Last 30 days',
80
+ '90d': 'Last 90 days',
81
+ qtd: 'Quarter to date',
82
+ ytd: 'Year to date',
83
+ custom: 'Custom',
84
+ };
85
+
86
+ const KNOWN_PRESETS = new Set(Object.keys(PRESET_LABELS));
87
+
88
+ /**
89
+ * Resolve a preset key to a {from, to} ISO pair against `now`. For
90
+ * `custom`, the caller supplies the from/to (or we fall back to the
91
+ * prior range — see #resolveRange). Returns null for unknown keys.
92
+ */
93
+ function resolvePreset(key, now = new Date()) {
94
+ const t0 = new Date(now.getFullYear(), now.getMonth(), now.getDate());
95
+ const iso = toISO(t0);
96
+ const sub = (days) => {
97
+ const d = new Date(t0);
98
+ d.setDate(d.getDate() - days);
99
+ return toISO(d);
100
+ };
101
+ switch (key) {
102
+ case 'today':
103
+ return { from: iso, to: iso };
104
+ case '7d':
105
+ return { from: sub(6), to: iso };
106
+ case '30d':
107
+ return { from: sub(29), to: iso };
108
+ case '90d':
109
+ return { from: sub(89), to: iso };
110
+ case 'qtd': {
111
+ // Calendar quarter only (Jan/Apr/Jul/Oct) — SPEC OD-002 lean A.
112
+ const qStartMonth = Math.floor(t0.getMonth() / 3) * 3;
113
+ return { from: toISO(new Date(t0.getFullYear(), qStartMonth, 1)), to: iso };
114
+ }
115
+ case 'ytd':
116
+ return { from: toISO(new Date(t0.getFullYear(), 0, 1)), to: iso };
117
+ case 'custom':
118
+ // Custom is consumer-supplied; caller resolves.
119
+ return null;
120
+ default:
121
+ return null;
122
+ }
123
+ }
124
+
125
+ /** Format an ISO range to a human-readable readout ("1 May – 7 May 2026"). */
126
+ function formatReadout(from, to) {
127
+ const f = parseISO(from);
128
+ const t = parseISO(to);
129
+ if (!f || !t) return '';
130
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
131
+ const sameYear = f.getFullYear() === t.getFullYear();
132
+ const sameMonthYear = sameYear && f.getMonth() === t.getMonth();
133
+ if (sameYear && f.getMonth() === t.getMonth() && f.getDate() === t.getDate()) {
134
+ return `${months[f.getMonth()]} ${f.getDate()}, ${f.getFullYear()}`;
135
+ }
136
+ if (sameMonthYear) {
137
+ return `${months[f.getMonth()]} ${f.getDate()} – ${t.getDate()}, ${t.getFullYear()}`;
138
+ }
139
+ if (sameYear) {
140
+ return `${months[f.getMonth()]} ${f.getDate()} – ${months[t.getMonth()]} ${t.getDate()}, ${t.getFullYear()}`;
141
+ }
142
+ return `${months[f.getMonth()]} ${f.getDate()}, ${f.getFullYear()} – ${months[t.getMonth()]} ${t.getDate()}, ${t.getFullYear()}`;
143
+ }
144
+
145
+ // ═══════════════════════════════════════════════════════════════
146
+ // UIDateRangeSelector
147
+ // ═══════════════════════════════════════════════════════════════
148
+
149
+ export class UIDateRangeSelector extends UIFormElement {
150
+ // Per AGENTS.md §170 / form.js — `label` is NOT first-class here.
151
+ // The host carries aria-label directly (see properties).
152
+ static labelDeprecated = true;
153
+
154
+ static get properties() {
155
+ return {
156
+ ...UIFormElement.properties,
157
+ // `value` is the preset key (today / 7d / ... / custom). Reflected.
158
+ value: { type: String, default: '7d', reflect: true },
159
+ from: { type: String, default: '', reflect: true },
160
+ to: { type: String, default: '', reflect: true },
161
+ presets: { type: String, default: 'today,7d,30d,90d,qtd,ytd,custom' },
162
+ broadcast: { type: String, default: 'document', reflect: true },
163
+ // Host aria-label, surfaced as a property for prop-set updates.
164
+ ariaLabelAttr: { type: String, default: 'Time period', attribute: 'aria-label', reflect: true },
165
+ pickerOpen: { type: Boolean, default: false, attribute: 'picker-open', reflect: true },
166
+ };
167
+ }
168
+
169
+ // No template — the composite imperatively stamps a fixed three-region
170
+ // skeleton (chips · picker · readout) once on first connect, then
171
+ // mutates per-region attributes across renders. Returning html`` would
172
+ // trigger stamp() → replaceChildren() and wipe the authored slots /
173
+ // composed children. Same pattern as confirm-dialog-ui / modal-ui.
174
+ static template = () => null;
175
+
176
+ // ── Field refs ───────────────────────────────────────────────────
177
+ #stamped = false;
178
+ #cleanups = [];
179
+ #segmented = null;
180
+ #picker = null;
181
+ #readout = null;
182
+ #regions = { prefix: null, chips: null, picker: null, readout: null, suffix: null };
183
+ // Tracks whether THIS instance currently owns the document broadcast.
184
+ // Set true on first connect when broadcast=document; cleared on disconnect.
185
+ #isDocumentOwner = false;
186
+ // Suppress nested attribute-change loops while the composite is
187
+ // resolving a range internally (preset click → #commit() → reflect
188
+ // value attribute → attributeChangedCallback → recompute).
189
+ #internalUpdate = false;
190
+
191
+ // Pre-render guard for SPEC anti-pattern: log a one-shot warn on
192
+ // unknown preset keys (so consumer-debugging is loud, not silent).
193
+ static #warnedUnknownPreset = new WeakSet();
194
+
195
+ // ── Public imperative API (SPEC §4 Methods) ──────────────────────
196
+
197
+ /**
198
+ * Programmatic equivalent of clicking a preset. For `custom`, pass
199
+ * from + to ISO strings; for named presets, from/to are derived.
200
+ * Fires `range-change` exactly once when the resolved range changes.
201
+ *
202
+ * @param {string} preset — one of the value enum keys
203
+ * @param {string} [fromISO] — required when preset === 'custom'
204
+ * @param {string} [toISO] — required when preset === 'custom'
205
+ */
206
+ setRange(preset, fromISO, toISO) {
207
+ if (preset === 'custom') {
208
+ if (!fromISO || !toISO) {
209
+ // SPEC §9 anti-pattern: custom requires from/to.
210
+ // eslint-disable-next-line no-console
211
+ console.warn('[date-range-selector-ui] setRange("custom", …) requires from + to ISO dates.');
212
+ return;
213
+ }
214
+ const ordered = this.#orderRange(fromISO, toISO);
215
+ this.#commit('custom', ordered.from, ordered.to);
216
+ return;
217
+ }
218
+ if (!KNOWN_PRESETS.has(preset)) {
219
+ // eslint-disable-next-line no-console
220
+ console.warn(`[date-range-selector-ui] unknown preset "${preset}" — ignored. Use one of: ${[...KNOWN_PRESETS].join(', ')}.`);
221
+ return;
222
+ }
223
+ const resolved = resolvePreset(preset);
224
+ if (!resolved) return;
225
+ this.#commit(preset, resolved.from, resolved.to);
226
+ }
227
+
228
+ /** Returns the currently resolved range + preset. */
229
+ getRange() {
230
+ return {
231
+ value: this.value,
232
+ preset: this.value,
233
+ from: this.from,
234
+ to: this.to,
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Helper for JS consumers — binds a handler to `range-change` on the
240
+ * current broadcast target and returns an unsubscribe function. The
241
+ * target is resolved at subscribe time; if [broadcast] changes later,
242
+ * the consumer must re-subscribe.
243
+ */
244
+ subscribe(handler) {
245
+ const target = this.#broadcastTarget();
246
+ if (!target) {
247
+ // broadcast="none" — emit nothing; subscriber unsubscribe is a no-op.
248
+ return () => {};
249
+ }
250
+ target.addEventListener('range-change', handler);
251
+ return () => target.removeEventListener('range-change', handler);
252
+ }
253
+
254
+ // ── Lifecycle ────────────────────────────────────────────────────
255
+
256
+ connected() {
257
+ super.connected();
258
+ this.setAttribute('role', 'group');
259
+ if (!this.hasAttribute('aria-label')) {
260
+ this.setAttribute('aria-label', this.ariaLabelAttr || 'Time period');
261
+ }
262
+
263
+ if (!this.#stamped) {
264
+ this.#stamp();
265
+ this.#wireListeners();
266
+ this.#stamped = true;
267
+ }
268
+
269
+ // Register as the document-broadcaster (one-per-page invariant)
270
+ // BEFORE the initial commit so #writeBroadcastAttributes sees the
271
+ // ownership flag set and writes data-range-* to <html>.
272
+ if (this.broadcast === 'document') {
273
+ this.#claimDocumentBroadcaster();
274
+ }
275
+
276
+ // Resolve initial range from [value]. If from/to are already set
277
+ // (declarative attributes), respect them for `custom`; otherwise
278
+ // derive from the preset key.
279
+ untracked(() => {
280
+ const initialResolved = this.#resolveInitial();
281
+ if (initialResolved) {
282
+ this.#commit(this.value, initialResolved.from, initialResolved.to, { initial: true });
283
+ }
284
+ });
285
+ }
286
+
287
+ disconnected() {
288
+ super.disconnected();
289
+ for (const fn of this.#cleanups) {
290
+ try { fn(); } catch { /* tolerate stale ref */ }
291
+ }
292
+ this.#cleanups.length = 0;
293
+ this.#releaseDocumentBroadcaster();
294
+ }
295
+
296
+ attributeChangedCallback(name, oldVal, newVal) {
297
+ super.attributeChangedCallback?.(name, oldVal, newVal);
298
+ if (!this.#stamped || this.#internalUpdate) return;
299
+
300
+ if (name === 'presets') {
301
+ this.#renderChips();
302
+ return;
303
+ }
304
+ if (name === 'broadcast') {
305
+ // Re-route future broadcasts. Clear stale doc attrs if we were the
306
+ // doc-owner and the new mode isn't document.
307
+ if (oldVal === 'document' && newVal !== 'document') {
308
+ this.#releaseDocumentBroadcaster();
309
+ }
310
+ if (newVal === 'document') {
311
+ this.#claimDocumentBroadcaster();
312
+ // Write current range to the new target.
313
+ this.#writeBroadcastAttributes();
314
+ } else if (newVal === 'self') {
315
+ this.#writeBroadcastAttributes();
316
+ }
317
+ return;
318
+ }
319
+ if (name === 'value' && oldVal !== newVal && newVal && KNOWN_PRESETS.has(newVal)) {
320
+ // Programmatic [value] flip → re-resolve range (unless `custom`,
321
+ // which depends on author-supplied from/to).
322
+ if (newVal !== 'custom') {
323
+ const resolved = resolvePreset(newVal);
324
+ if (resolved) this.#commit(newVal, resolved.from, resolved.to);
325
+ }
326
+ return;
327
+ }
328
+ }
329
+
330
+ // ── Form participation ───────────────────────────────────────────
331
+
332
+ /**
333
+ * Override syncValue so the form value is the colon-pair "{from}:{to}".
334
+ * For pickers/selectors that don't have a meaningful single-string
335
+ * value, this is the de-facto convention.
336
+ */
337
+ syncValue(_val) {
338
+ const formVal = this.from && this.to ? `${this.from}:${this.to}` : '';
339
+ this.internals.setFormValue(formVal);
340
+ }
341
+
342
+ // ── Render — narrow; only mirrors current state to per-region nodes ──
343
+
344
+ render() {
345
+ if (!this.#stamped) return;
346
+ // Sync segmented selection to current preset key.
347
+ if (this.#segmented && this.#segmented.value !== this.value) {
348
+ this.#segmented.value = this.value;
349
+ }
350
+ // Sync picker open state into reflected [picker-open] attr (set by
351
+ // the picker's `open`/`close` events — see #wireListeners).
352
+ if (this.#picker) {
353
+ // Cascade min/max if author put them on the host (rare but
354
+ // documented in SPEC-037).
355
+ if (this.hasAttribute('min')) this.#picker.setAttribute('min', this.getAttribute('min'));
356
+ if (this.hasAttribute('max')) this.#picker.setAttribute('max', this.getAttribute('max'));
357
+
358
+ // Mirror the resolved range into the picker's value so segmented
359
+ // preset clicks (e.g. "30d") propagate into the picker — without
360
+ // this, clicking a chip resolved the range on the host (this.from
361
+ // / this.to / value) but the picker stayed at its prior state,
362
+ // so opening the picker afterward showed empty or stale dates.
363
+ // value is a JSON-string at the attribute boundary per the
364
+ // picker's UIFormElement.properties contract (see
365
+ // date-range-picker class.js); empty string clears the picker.
366
+ const targetValue = (this.from && this.to)
367
+ ? JSON.stringify({ from: this.from, to: this.to })
368
+ : '';
369
+ if (this.#picker.value !== targetValue) {
370
+ this.#picker.value = targetValue;
371
+ }
372
+ }
373
+ // Readout text mirrors from/to.
374
+ if (this.#readout) {
375
+ const txt = formatReadout(this.from, this.to);
376
+ this.#readout.textContent = txt;
377
+ // Hide when empty so the readout doesn't reserve space.
378
+ this.#readout.hidden = !txt;
379
+ }
380
+ }
381
+
382
+ // ── Internal — DOM stamping ──────────────────────────────────────
383
+
384
+ #stamp() {
385
+ // Chip row — a single <segmented-ui> with one <segment-ui> per preset.
386
+ const chips = document.createElement('segmented-ui');
387
+ chips.setAttribute('data-region', 'chips');
388
+ chips.setAttribute('role', 'radiogroup');
389
+ chips.setAttribute('aria-label', 'Preset ranges');
390
+ this.appendChild(chips);
391
+ this.#segmented = chips;
392
+ this.#regions.chips = chips;
393
+
394
+ // Picker — embedded <date-range-picker-ui> for the `custom` preset.
395
+ // Hidden via CSS when value != "custom" (see date-range-selector.css).
396
+ const picker = document.createElement('date-range-picker-ui');
397
+ picker.setAttribute('data-region', 'picker');
398
+ picker.setAttribute('format', 'short');
399
+ picker.setAttribute('placeholder', 'Custom range');
400
+ this.appendChild(picker);
401
+ this.#picker = picker;
402
+ this.#regions.picker = picker;
403
+
404
+ // Readout — a <text-ui> showing the resolved range when value="custom".
405
+ // For non-custom presets the chip label communicates the range; the
406
+ // readout stays hidden to reduce visual noise. Authors can override
407
+ // via the [data-show-readout] attribute on the host (not yet wired).
408
+ const readout = document.createElement('text-ui');
409
+ readout.setAttribute('data-region', 'readout');
410
+ readout.setAttribute('part', 'readout');
411
+ readout.setAttribute('role', 'status');
412
+ readout.setAttribute('aria-live', 'polite');
413
+ readout.hidden = true;
414
+ this.appendChild(readout);
415
+ this.#readout = readout;
416
+ this.#regions.readout = readout;
417
+
418
+ // Render the initial chip list.
419
+ this.#renderChips();
420
+ }
421
+
422
+ #renderChips() {
423
+ if (!this.#segmented) return;
424
+ const presetList = (this.presets || '')
425
+ .split(',')
426
+ .map((s) => s.trim())
427
+ .filter(Boolean);
428
+
429
+ // Wipe + restamp existing chips. Preset cardinality is small (≤7).
430
+ // Preserve the segmented's auto-stamped indicator span (the
431
+ // [data-indicator] node prepended by segmented-ui on first render);
432
+ // re-creating it would jump the indicator transition.
433
+ const indicator = this.#segmented.querySelector(':scope > [data-indicator]');
434
+ this.#segmented.replaceChildren();
435
+ if (indicator) this.#segmented.appendChild(indicator);
436
+
437
+ for (const key of presetList) {
438
+ if (!KNOWN_PRESETS.has(key)) {
439
+ if (!UIDateRangeSelector.#warnedUnknownPreset.has(this)) {
440
+ UIDateRangeSelector.#warnedUnknownPreset.add(this);
441
+ // eslint-disable-next-line no-console
442
+ console.warn(`[date-range-selector-ui] unknown preset key "${key}" in [presets] — ignored. Use one of: ${[...KNOWN_PRESETS].join(', ')}.`);
443
+ }
444
+ continue;
445
+ }
446
+ const seg = document.createElement('segment-ui');
447
+ seg.setAttribute('value', key);
448
+ seg.setAttribute('text', PRESET_LABELS[key]);
449
+ this.#segmented.appendChild(seg);
450
+ }
451
+
452
+ // Re-sync segmented's [value] so the indicator lands on the right chip.
453
+ this.#segmented.value = this.value;
454
+ }
455
+
456
+ #wireListeners() {
457
+ // ── Chip row → preset click ──
458
+ const onSegmentedChange = (e) => {
459
+ const presetKey = e.detail?.value;
460
+ if (!presetKey || !KNOWN_PRESETS.has(presetKey)) return;
461
+ e.stopPropagation();
462
+ if (presetKey === 'custom') {
463
+ // Open the picker; commit on its `change` event below.
464
+ this.#openPicker();
465
+ // Also flip value so [value="custom"] selectors gate the picker
466
+ // visibility per the CSS rule. Range stays at the previous
467
+ // resolved value until the picker commits.
468
+ this.#setValueOnly('custom');
469
+ return;
470
+ }
471
+ const resolved = resolvePreset(presetKey);
472
+ if (!resolved) return;
473
+ this.#commit(presetKey, resolved.from, resolved.to);
474
+ };
475
+ this.#segmented.addEventListener('change', onSegmentedChange);
476
+ this.#cleanups.push(() => this.#segmented.removeEventListener('change', onSegmentedChange));
477
+
478
+ // ── Picker → custom range commit ──
479
+ const onPickerChange = (e) => {
480
+ e.stopPropagation();
481
+ const detail = e.detail || {};
482
+ const val = detail.value || null;
483
+ if (!val || !val.from || !val.to) return;
484
+ const ordered = this.#orderRange(val.from, val.to);
485
+ this.#commit('custom', ordered.from, ordered.to);
486
+ };
487
+ this.#picker.addEventListener('change', onPickerChange);
488
+ this.#cleanups.push(() => this.#picker.removeEventListener('change', onPickerChange));
489
+
490
+ // Reflect picker open/close into [picker-open] on the host.
491
+ const onPickerOpen = () => {
492
+ this.pickerOpen = true;
493
+ };
494
+ const onPickerClose = () => {
495
+ this.pickerOpen = false;
496
+ };
497
+ this.#picker.addEventListener('open', onPickerOpen);
498
+ this.#picker.addEventListener('close', onPickerClose);
499
+ this.#cleanups.push(() => this.#picker.removeEventListener('open', onPickerOpen));
500
+ this.#cleanups.push(() => this.#picker.removeEventListener('close', onPickerClose));
501
+ }
502
+
503
+ // ── Range commit pipeline ────────────────────────────────────────
504
+
505
+ /**
506
+ * Single commit point. Updates from/to/value, syncs form value,
507
+ * dispatches `range-change` on the broadcast target + `change` on the
508
+ * host, and writes data-range-* attributes per [broadcast].
509
+ *
510
+ * @param {string} presetKey
511
+ * @param {string} fromISO
512
+ * @param {string} toISO
513
+ * @param {{initial?: boolean}} [opts] — when initial, the from/to may
514
+ * already match the props; we still emit so subscribers wired at
515
+ * connect-time see the first range.
516
+ */
517
+ #commit(presetKey, fromISO, toISO, opts = {}) {
518
+ const sameRange = this.from === fromISO && this.to === toISO && this.value === presetKey;
519
+ if (sameRange && !opts.initial) return;
520
+
521
+ this.#internalUpdate = true;
522
+ try {
523
+ this.from = fromISO;
524
+ this.to = toISO;
525
+ this.value = presetKey;
526
+ } finally {
527
+ this.#internalUpdate = false;
528
+ }
529
+
530
+ this.syncValue();
531
+ this.#writeBroadcastAttributes();
532
+
533
+ const detail = {
534
+ value: presetKey,
535
+ from: fromISO,
536
+ to: toISO,
537
+ preset: presetKey,
538
+ };
539
+
540
+ // Host-level `change` event (UIFormElement convention + alias).
541
+ this.dispatchEvent(new CustomEvent('change', {
542
+ bubbles: true,
543
+ detail: { value: presetKey, from: fromISO, to: toISO },
544
+ }));
545
+
546
+ // Broadcast `range-change` on the configured target.
547
+ const target = this.#broadcastTarget();
548
+ if (target) {
549
+ target.dispatchEvent(new CustomEvent('range-change', {
550
+ bubbles: target === this, // bubble on self; not meaningful on document
551
+ detail,
552
+ }));
553
+ }
554
+ }
555
+
556
+ /**
557
+ * Set [value] without re-resolving the range. Used when the user clicks
558
+ * the Custom chip — we flip the chip selection synchronously so the
559
+ * picker visibility CSS rule kicks in, but the range stays at the
560
+ * prior commit until the picker emits `change`.
561
+ */
562
+ #setValueOnly(presetKey) {
563
+ if (this.value === presetKey) return;
564
+ this.#internalUpdate = true;
565
+ try {
566
+ this.value = presetKey;
567
+ } finally {
568
+ this.#internalUpdate = false;
569
+ }
570
+ }
571
+
572
+ #orderRange(fromISO, toISO) {
573
+ return fromISO <= toISO
574
+ ? { from: fromISO, to: toISO }
575
+ : { from: toISO, to: fromISO };
576
+ }
577
+
578
+ /** Resolve the from/to to use on first connect from current props. */
579
+ #resolveInitial() {
580
+ if (this.value === 'custom') {
581
+ if (this.from && this.to) {
582
+ const ordered = this.#orderRange(this.from, this.to);
583
+ return ordered;
584
+ }
585
+ // SPEC §9 anti-pattern: custom with no from/to. Fall back to 7d
586
+ // so first paint has a meaningful range; chip selection stays on
587
+ // `custom` to surface the picker.
588
+ return resolvePreset('7d');
589
+ }
590
+ const known = KNOWN_PRESETS.has(this.value) ? this.value : '7d';
591
+ return resolvePreset(known);
592
+ }
593
+
594
+ // ── Broadcast plumbing ───────────────────────────────────────────
595
+
596
+ /**
597
+ * The DOM target we dispatch range-change on (or null for broadcast=none).
598
+ * For `document`, we dispatch on `document` directly so consumers can
599
+ * listen with `document.addEventListener('range-change', …)` per
600
+ * SPEC §8. The data-range-* attributes still land on `<html>` (i.e.
601
+ * `document.documentElement`) so CSS-only subscribers match
602
+ * `html[data-range-from]` selectors.
603
+ */
604
+ #broadcastTarget() {
605
+ if (this.broadcast === 'document') return document;
606
+ if (this.broadcast === 'self') return this;
607
+ return null;
608
+ }
609
+
610
+ /** Target the data-range-* attributes are written to. */
611
+ #attributeTarget() {
612
+ if (this.broadcast === 'document') return document.documentElement;
613
+ if (this.broadcast === 'self') return this;
614
+ return null;
615
+ }
616
+
617
+ #writeBroadcastAttributes() {
618
+ const target = this.#attributeTarget();
619
+ if (!target) return;
620
+ // Only the document-owner writes to <html>. If a second instance
621
+ // claims doc broadcaster (multi-broadcaster scenario), only the
622
+ // claimant writes; others abstain.
623
+ if (this.broadcast === 'document' && !this.#isDocumentOwner) return;
624
+ if (this.from) target.setAttribute('data-range-from', this.from);
625
+ else target.removeAttribute('data-range-from');
626
+ if (this.to) target.setAttribute('data-range-to', this.to);
627
+ else target.removeAttribute('data-range-to');
628
+ }
629
+
630
+ #claimDocumentBroadcaster() {
631
+ if (documentBroadcasters.size > 0 && !documentBroadcasters.has(this)) {
632
+ // Second broadcaster on the page. Per SPEC anti-pattern 2, warn
633
+ // once + let the original keep ownership. The new instance still
634
+ // emits range-change events on document (subscribers can listen),
635
+ // but does not write the data-range-* attributes (so CSS-only
636
+ // subscribers don't flicker).
637
+ if (!multiBroadcasterWarned) {
638
+ multiBroadcasterWarned = true;
639
+ // eslint-disable-next-line no-console
640
+ console.warn(
641
+ '[date-range-selector-ui] multiple instances with broadcast="document" on the same page; ' +
642
+ 'only the first writes data-range-from / data-range-to to <html>. Use broadcast="self" for ' +
643
+ 'secondary instances. See SPEC-019 §6 nesting rules.',
644
+ );
645
+ }
646
+ this.#isDocumentOwner = false;
647
+ documentBroadcasters.add(this);
648
+ return;
649
+ }
650
+ documentBroadcasters.add(this);
651
+ this.#isDocumentOwner = true;
652
+ }
653
+
654
+ #releaseDocumentBroadcaster() {
655
+ if (!documentBroadcasters.has(this)) return;
656
+ documentBroadcasters.delete(this);
657
+ if (this.#isDocumentOwner) {
658
+ // Clear the document attributes — we owned them.
659
+ document.documentElement.removeAttribute('data-range-from');
660
+ document.documentElement.removeAttribute('data-range-to');
661
+ this.#isDocumentOwner = false;
662
+ // Promote the next broadcaster (if any) to owner so subsequent
663
+ // commits write the attributes again.
664
+ const next = documentBroadcasters.values().next().value;
665
+ if (next) {
666
+ next.#isDocumentOwner = true;
667
+ next.#writeBroadcastAttributes();
668
+ }
669
+ }
670
+ }
671
+
672
+ // ── Imperative picker open helper ────────────────────────────────
673
+
674
+ #openPicker() {
675
+ if (!this.#picker) return;
676
+ if (typeof this.#picker.openPopover === 'function') {
677
+ this.#picker.openPopover();
678
+ } else {
679
+ this.#picker.open = true;
680
+ }
681
+ }
682
+ }
683
+
684
+ if (!customElements.get('date-range-selector-ui')) {
685
+ customElements.define('date-range-selector-ui', UIDateRangeSelector);
686
+ }