@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.
- package/CHANGELOG.md +17 -0
- package/README.md +3 -3
- package/agent-admin/admin-roster/admin-roster.js +1 -1
- package/agent-admin/admin-settings/admin-settings.js +1 -1
- package/agent-admin/agent-admin/agent-admin.js +1 -1
- package/billing/billing-overview/billing-overview.a2ui.json +281 -0
- package/billing/billing-overview/billing-overview.class.js +838 -0
- package/billing/billing-overview/billing-overview.css +223 -0
- package/billing/billing-overview/billing-overview.js +16 -0
- package/billing/billing-overview/billing-overview.yaml +441 -0
- package/billing/index.js +21 -0
- package/billing/invoice-detail/invoice-detail.a2ui.json +276 -0
- package/billing/invoice-detail/invoice-detail.class.js +711 -0
- package/billing/invoice-detail/invoice-detail.css +305 -0
- package/billing/invoice-detail/invoice-detail.js +16 -0
- package/billing/invoice-detail/invoice-detail.yaml +411 -0
- package/billing/invoice-history/invoice-history.a2ui.json +315 -0
- package/billing/invoice-history/invoice-history.class.js +672 -0
- package/billing/invoice-history/invoice-history.css +130 -0
- package/billing/invoice-history/invoice-history.js +16 -0
- package/billing/invoice-history/invoice-history.yaml +398 -0
- package/billing/payment-method-form/payment-method-form.a2ui.json +310 -0
- package/billing/payment-method-form/payment-method-form.class.js +886 -0
- package/billing/payment-method-form/payment-method-form.css +149 -0
- package/billing/payment-method-form/payment-method-form.js +16 -0
- package/billing/payment-method-form/payment-method-form.yaml +430 -0
- package/billing/payment-method-list/payment-method-list.a2ui.json +283 -0
- package/billing/payment-method-list/payment-method-list.class.js +705 -0
- package/billing/payment-method-list/payment-method-list.css +128 -0
- package/billing/payment-method-list/payment-method-list.js +16 -0
- package/billing/payment-method-list/payment-method-list.yaml +355 -0
- package/billing/plan-picker/plan-picker.a2ui.json +349 -0
- package/billing/plan-picker/plan-picker.class.js +635 -0
- package/billing/plan-picker/plan-picker.css +227 -0
- package/billing/plan-picker/plan-picker.js +16 -0
- package/billing/plan-picker/plan-picker.yaml +453 -0
- package/chat/README.md +1 -1
- package/chat/chat-composer/chat-composer.js +2 -2
- package/chat/chat-empty/chat-empty.js +2 -2
- package/chat/chat-shell/chat-shell.js +4 -4
- package/chat/chat-sidebar/chat-sidebar.js +2 -2
- package/chat/chat-surfaces/chat-surfaces.js +2 -2
- package/chat/chat-thread/chat-thread.js +2 -2
- package/dashboard/dashboard-layout/dashboard-layout.a2ui.json +254 -0
- package/dashboard/dashboard-layout/dashboard-layout.css +190 -0
- package/dashboard/dashboard-layout/dashboard-layout.js +289 -0
- package/dashboard/dashboard-layout/dashboard-layout.yaml +374 -0
- package/dashboard/date-range-selector/date-range-selector.a2ui.json +236 -0
- package/dashboard/date-range-selector/date-range-selector.css +84 -0
- package/dashboard/date-range-selector/date-range-selector.js +686 -0
- package/dashboard/date-range-selector/date-range-selector.yaml +358 -0
- package/dashboard/index.js +14 -0
- package/dist/everything.min.js +1 -1
- package/editor/editor-canvas/editor-canvas.js +2 -2
- package/editor/editor-shell/editor-shell.js +2 -2
- package/editor/editor-sidebar/editor-sidebar.js +2 -2
- package/editor/editor-toolbar/editor-toolbar.js +2 -2
- package/feedback/confirm-dialog/confirm-dialog.a2ui.json +206 -0
- package/feedback/confirm-dialog/confirm-dialog.css +157 -0
- package/feedback/confirm-dialog/confirm-dialog.js +383 -0
- package/feedback/confirm-dialog/confirm-dialog.yaml +297 -0
- package/feedback/index.js +10 -0
- package/form/form-popover/form-popover.js +1 -1
- package/generative/index.d.ts +1 -1
- package/generative/index.js +2 -2
- package/onboarding/index.js +1 -0
- package/onboarding/onboarding-checklist/onboarding-checklist.a2ui.json +263 -0
- package/onboarding/onboarding-checklist/onboarding-checklist.css +157 -0
- package/onboarding/onboarding-checklist/onboarding-checklist.js +428 -0
- package/onboarding/onboarding-checklist/onboarding-checklist.yaml +236 -0
- package/package.json +99 -6
- package/runtime/a2ui-root/a2ui-root.js +3 -3
- package/runtime/gen-root/gen-root.js +5 -5
- package/settings/index.js +12 -0
- package/settings/integrations-page/integrations-page.a2ui.json +274 -0
- package/settings/integrations-page/integrations-page.css +177 -0
- package/settings/integrations-page/integrations-page.d.ts +77 -0
- package/settings/integrations-page/integrations-page.js +413 -0
- package/settings/integrations-page/integrations-page.yaml +317 -0
- package/settings/notification-preferences/notification-preferences.a2ui.json +245 -0
- package/settings/notification-preferences/notification-preferences.css +204 -0
- package/settings/notification-preferences/notification-preferences.js +622 -0
- package/settings/notification-preferences/notification-preferences.yaml +358 -0
- package/shell/admin-command/admin-command.js +2 -2
- package/shell/admin-shell/admin-shell.js +2 -2
- package/shell/admin-sidebar/admin-sidebar.js +2 -2
- package/shell/embed-shell/embed-shell.js +3 -3
- package/simple/simple-shell/simple-shell.js +1 -1
- 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
|
+
}
|